Nätverksanrop med fetch
fetch() är webbläsarens API för nätverksanrop. Det ersätter det äldre XMLHttpRequest, som du kan
stöta på i äldre kod och i inspelningarna, och returnerar ett promise. Den här artikeln sätter ihop
de två föregående: HTTP och JSON beskriver vad som skickas, och
Promises och async/await hur du väntar på svaret.
Exemplen bygger på ett tänkt API för böcker, api.example.com, och en komponent, book-search, som
söker bland böckerna. Veckans övningsuppgift, Wikisök, gör samma sak mot
Wikipedia.
Tidsåtgång: Ungefär 25 minuter.
Hämta data
Section titled “Hämta data”Den enklaste förfrågan är en GET till en URL:
const response = await fetch('https://api.example.com/books/42')const book = await response.json()Svaret kommer i två steg, och därför behövs två await. Promiset från fetch() blir fulfilled så
snart statusraden och headrarna har kommit, med ett Response-objekt. Bodyn kan fortfarande vara på
väg. response.json() läser resten av bodyn och tolkar den som JSON, och returnerar ett nytt
promise. Är bodyn inte JSON blir det rejected med ett SyntaxError, precis som JSON.parse().
Response har bland annat:
status– statuskoden, till exempel200eller404,ok–trueom statuskoden ligger mellan 200 och 299,headers– svarets headrar, till exempelresponse.headers.get('Content-Type'),json()ochtext()– läser bodyn som JSON respektive text.
Bodyn kan bara läsas en gång. Anropar du response.json() två gånger misslyckas det andra anropet.
Kontrollera svaret
Section titled “Kontrollera svaret”Det viktigaste att veta om fetch() är när promiset blir rejected:
| Vad händer | Promiset från fetch() |
|---|---|
Servern svarar med 200 |
fulfilled, response.ok är true |
Servern svarar med 404 eller 500 |
fulfilled, response.ok är false |
| Inget nätverk, eller servern går inte att nå | rejected med ett TypeError |
| Webbläsaren stoppar förfrågan med CORS | rejected med ett TypeError |
| Förfrågan avbryts | rejected med ett DOMException |
Ett svar med en felkod är fortfarande ett svar, och promiset från fetch() blir fulfilled. Det är din
kod som måste titta på response.ok och avgöra vad som ska hända:
async function getBook(id) { const response = await fetch(`https://api.example.com/books/${id}`)
if (!response.ok) { throw new Error(`Could not load book ${id} (status ${response.status}).`) }
return response.json()}Kastar funktionen ett fel för felkoder hamnar alla misslyckanden på samma ställe, i den anropande
kodens catch.
Fråga: vad händer utan kontrollen?
Section titled “Fråga: vad händer utan kontrollen?”try { const response = await fetch('https://api.example.com/books/999') const book = await response.json() title.textContent = book.title} catch { title.textContent = 'The book could not be loaded.'}Bok 999 finns inte, och servern svarar 404 Not Found. Vad visas om bodyn i svaret är en
HTML-sida? Och om den är { "error": "Not found" }?
I det första fallet visas felmeddelandet, men av fel skäl. Promiset från fetch() blir fulfilled,
och det är response.json() som misslyckas med ett SyntaxError när den försöker tolka HTML som
JSON. Koden hamnar rätt, men felet du ser i konsolen handlar om JSON, inte om att boken saknas.
I det andra fallet är bodyn giltig JSON. book blir objektet { error: 'Not found' }, och
book.title är undefined. Rubriken blir tom, och inget fel syns någonstans.
Med kontrollen av response.ok blir båda fallen ett fel med statuskoden i meddelandet, innan
bodyn ens läses.
Felmeddelanden som JSON
Section titled “Felmeddelanden som JSON”Många API:er berättar vad som gick fel i bodyn, ofta som JSON. Det finns en standard för det,
Problem Details, där svaret har
Content-Type: application/problem+json:
HTTP/1.1 404 Not FoundContent-Type: application/problem+json
{ "type": "https://api.example.com/problems/book-not-found", "title": "Book not found", "status": 404, "detail": "There is no book with id 999."}Vill du ta med den informationen till den som anropar räcker inte ett vanligt Error. Gör en egen
felklass som bär med sig statuskoden och bodyn:
export class HttpError extends Error { constructor(response, body) { super(`Request failed with status ${response.status}.`) this.name = 'HttpError' this.status = response.status this.body = body }}
async function readBody(response) { const type = response.headers.get('Content-Type') ?? '' return type.includes('json') ? response.json() : response.text()}
export async function fetchJson(url, options) { const response = await fetch(url, options) const body = await readBody(response)
if (!response.ok) { throw new HttpError(response, body) }
return body}readBody() läser bodyn utifrån Content-Type, så att både JSON, problem details, en HTML-felsida
och ett tomt 204-svar fungerar. Är bodyn märkt som JSON men ändå ogiltig kastas ett SyntaxError,
och det ska den: då är något verkligen fel.
Den som anropar har nu alla fel på ett ställe och kan skilja dem åt:
try { const book = await fetchJson(url, { signal }) this.#showBook(book)} catch (error) { if (error.name === 'AbortError') { return } if (error instanceof HttpError && error.status === 404) { this.#showMessage('There is no such book.') return } this.#showError('The book could not be loaded. Please try again.')}Två saker att undvika, som du kan stöta på i kod på nätet:
- Att returnera fel i stället för att kasta dem, till exempel som
{ success: false, error }. Då måste den som anropar både fånga undantag och kontrollera resultatet, och glömmer den det ena försvinner felet. - Att packa in felet från
fetch(), somthrow new Error('Network error'). EttAbortErrorblir då ett vanligt fel och går inte längre att känna igen.TypeErrorochAbortErrorfrånfetch()säger redan vad som hänt; låt dem passera.
Bygg inte heller felmeddelanden på response.statusText. HTTP/2 och HTTP/3 har ingen statustext,
och då är den en tom sträng.
Bygga URL:en
Section titled “Bygga URL:en”Data till en GET skickas i query stringen. Klistra inte ihop den med strängar; tecken som &, ?
och mellanslag i användarens text kan då ändra URL:ens betydelse. Låt URL och dess
searchParams sköta kodningen:
const url = new URL('https://api.example.com/books')url.searchParams.set('author', 'Selma Lagerlöf')url.searchParams.set('sort', 'year')
console.log(url.href)// https://api.example.com/books?author=Selma+Lagerl%C3%B6f&sort=year
const response = await fetch(url)fetch() tar emot ett URL-objekt lika gärna som en sträng.
Skicka data
Section titled “Skicka data”För att skicka data anger du ett andra argument, ett objekt med inställningar för förfrågan:
const response = await fetch('https://api.example.com/books', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Gösta Berlings saga', author: 'Selma Lagerlöf' })})Tre saker hör ihop här: metoden, headern Content-Type som talar om att bodyn är JSON, och bodyn
själv, som måste vara en sträng. fetch() gör inte om objekt till JSON åt dig. Glömmer du
JSON.stringify() skickas texten [object Object], och glömmer du headern kan servern svara
415 Unsupported Media Type.
Från ett formulär
Section titled “Från ett formulär”Data som användaren skriver in kommer oftast från ett formulär. Lyssna på formulärets submit, inte
på ett klick på knappen, eftersom ett formulär också kan skickas med Enter.
FormData läser alla fält med ett name på en gång:
this.#form.addEventListener('submit', (event) => { event.preventDefault() this.#save(new FormData(this.#form))}, { signal })async #save(formData) { this.#submitButton.disabled = true
try { const response = await fetch('https://api.example.com/books', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(formData)) })
if (!response.ok) { throw new Error(`Could not save the book (status ${response.status}).`) }
this.#form.reset() } catch { this.#showError('The book could not be saved. Please try again.') } finally { this.#submitButton.disabled = false }}Object.fromEntries(formData) gör om fälten till ett vanligt objekt, { title: '...', author: '...' },
som kan skrivas som JSON. Knappen inaktiveras medan förfrågan pågår, så att en otålig användare inte
skickar samma bok två gånger, och aktiveras igen i finally() oavsett hur det gick.
Avbryta en förfrågan
Section titled “Avbryta en förfrågan”Ibland behövs inte svaret längre. Användaren har lämnat vyn, komponenten har tagits bort eller en
nyare förfrågan har gjort den gamla inaktuell. Då ska den gamla avbrytas, med samma AbortController
som du använder för lyssnare:
const controller = new AbortController()
fetch(url, { signal: controller.signal })
controller.abort()När abort() anropas avbryter webbläsaren förfrågan, och promiset blir rejected med ett
DOMException vars name är 'AbortError'. Det är inget fel att visa för användaren, eftersom det
var din egen kod som avbröt. Fånga det och gör ingenting:
catch (error) { if (error.name === 'AbortError') { return } this.#showError('The search failed. Please try again.')}Vill du ge upp efter en viss tid finns AbortSignal.timeout(), som avbryter av sig själv. Felet har
då name satt till 'TimeoutError':
const response = await fetch(url, { signal: AbortSignal.timeout(5000) })Race condition: det gamla svaret kommer sist
Section titled “Race condition: det gamla svaret kommer sist”book-search söker medan användaren skriver, med en förfrågan för varje tangenttryckning. Det finns
ingen garanti för att svaren kommer tillbaka i samma ordning som förfrågningarna skickades. En
sökning på ett kort ord kan ge fler träffar och ta längre tid än en sökning på ett längre.
Det kallas en race condition: resultatet beror på vilket svar som råkar komma först. Lösningen är
att avbryta den förra förfrågan innan en ny skickas. Komponentens AbortController för lyssnarna
räcker inte till det, eftersom den lever lika länge som komponenten är ansluten. Sökningen behöver en
egen, som byts ut för varje sökning:
#searchController
async #search(term) { this.#searchController?.abort() this.#searchController = new AbortController()
const url = new URL('https://api.example.com/books') url.searchParams.set('q', term)
try { const response = await fetch(url, { signal: this.#searchController.signal })
if (!response.ok) { throw new Error(`Search failed (status ${response.status}).`) }
this.#showBooks(await response.json()) } catch (error) { if (error.name === 'AbortError') { return } this.#showError('The search failed. Please try again.') }}
disconnectedCallback() { this.#abortController.abort() this.#searchController?.abort()}Varje ny sökning avbryter den förra, så bara den senaste kan visa sitt resultat. Och när
komponenten tas bort avbryts även en pågående sökning, precis som lyssnarna. Allt komponenten har
startat stoppas i disconnectedCallback(), som du läste i
Händelser i webbkomponenter.
Vem pratar med servern?
Section titled “Vem pratar med servern?”I en applikation byggd av flera komponenter är det lättare att följa flödet om en komponent äger
kommunikationen med servern, i stället för att varje liten del skickar egna förfrågningar. De andra komponenterna
får data ned via attribut och metoder och berättar vad användaren har gjort med egna händelser. Det
är så förslaget på arkitektur i examinationsuppgift B2
är tänkt: quiz-app skickar alla förfrågningar.
Förr eller senare ser du det här i konsolen:
Access to fetch at 'https://api.example.com/books' from origin 'http://localhost:5173'has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is presenton the requested resource.Same-origin policy
Section titled “Same-origin policy”Varje sida har en origin (ursprung): kombinationen av protokoll, värdnamn och port i URL:en.
Under utveckling med Vite är sidans origin http://localhost:5173.
Av säkerhetsskäl får JavaScript på en sida bara läsa svar från sin egen origin. Annars skulle en sida du besöker kunna läsa data från andra webbplatser där du är inloggad, eller från servrar i ett internt nätverk som din dator når men sidans ägare inte gör. Regeln kallas same-origin policy, och gäller förfrågningar från JavaScript. Bilder, stilmallar och skript från andra origins laddas som vanligt.
CORS (Cross-Origin Resource Sharing) är sättet för en server att göra undantag. Servern svarar
med headern Access-Control-Allow-Origin, som talar om vilka origins som får läsa svaret. Det är
alltså servern som bestämmer, och webbläsaren som ser till att beslutet följs.
Fråga: samma origin?
Section titled “Fråga: samma origin?”Din sida körs på http://localhost:5173. Vilka av de här URL:erna har samma origin?
http://localhost:5173/api/bookshttp://localhost:3000/api/bookshttp://127.0.0.1:5173/api/bookshttps://localhost:5173/api/books
Bara den första. Sökvägen spelar ingen roll, men protokoll, värdnamn och port måste vara exakt samma.
Den andra har en annan port. Den tredje pekar på samma dator, men webbläsaren jämför värdnamnet som
text, och 127.0.0.1 är inte localhost. Den fjärde har ett annat protokoll.
Det betyder att ett API som körs på din egen dator på en annan port också räknas som en annan origin.
Simple requests och preflight
Section titled “Simple requests och preflight”Webbläsaren skiljer på två sorters förfrågningar till andra origins.
En simple request är en GET, eller en POST med en body av den sort ett vanligt HTML-formulär
kan skicka, utan egna headrar. Den skickar webbläsaren direkt, med headern Origin. Saknas rätt
Access-Control-Allow-Origin i svaret får din kod inte läsa det.
Alla andra förfrågningar föregås av en preflight-förfrågan. Det gäller bland annat PUT, PATCH och
DELETE, och varje POST med Content-Type: application/json. Webbläsaren frågar då först servern
med metoden OPTIONS om förfrågan är tillåten, och skickar den riktiga förfrågan bara om svaret är ja. Så
här ser det ut mot API:et i B2:
Preflight-förfrågan syns i fliken Network som en egen rad, med metoden OPTIONS eller typen
preflight.
Stoppas en förfrågan får din kod inte veta varför. Promiset blir rejected med ett TypeError, med samma
meddelande som när nätverket inte fungerar. Orsaken står bara i konsolen. Det är avsiktligt: annars
skulle ett skript kunna kartlägga vilka servrar och URL:er som finns i ett nätverk det inte har
tillgång till.
Fråga: når förfrågan servern?
Section titled “Fråga: når förfrågan servern?”Din kod gör en GET till ett API på en annan origin som inte skickar något
Access-Control-Allow-Origin. Förfrågan stoppas med CORS. Har servern fått förfrågan? Och om det i
stället var en DELETE?
GET-förfrågan har nått servern. Den är en simple request, så webbläsaren skickar den direkt, och
servern svarar. Det är först när svaret kommer tillbaka som webbläsaren ser att headern saknas och
vägrar ge din kod svaret.
DELETE kräver en preflight-förfrågan. Servern svarar på OPTIONS utan att tillåta förfrågan, och
webbläsaren skickar aldrig själva DELETE-förfrågan.
Slutsatsen är att CORS skyddar användarens data i webbläsaren, inte servern. En server måste alltid
själv kontrollera vem som får göra vad, eftersom vem som helst kan skicka förfrågningar till den
utan en webbläsare, till exempel med curl eller Postman.
När API:et inte tillåter din origin
Section titled “När API:et inte tillåter din origin”CORS stoppas inte av något i din kod, och går inte att stänga av från den. Det finns två vägar:
- Ändra servern, om den är din, så att den skickar rätt
Access-Control-Allow-*-headrar. - Gå via en egen server. CORS gäller bara i webbläsaren. Din egen server kan anropa API:et och
skicka svaret vidare till din sida, från samma origin. Vite kan göra det under utveckling med
inställningen
server.proxy.
Många publika API:er tillåter alla origins. Wikipedias API gör det för förfrågningar utan inloggning när
URL:en innehåller origin=*, vilket är skälet till parametern i URL:erna i Wikisök.
Att ta med sig
Section titled “Att ta med sig”await fetch(url)ger ettResponsenär headrarna har kommit.await response.json()läser och tolkar bodyn.- Promiset från
fetch()blir rejected bara när inget svar kan läsas: nätverksfel, CORS eller avbrott. Kontrolleraresponse.oksjälv och kasta ett fel för felkoder, gärna en egen felklass med statuskoden och felbodyn. - Bygg URL:er med
URLochsearchParams. Skicka JSON medmethod,Content-TypeochJSON.stringify(). - Avbryt förfrågningar med
AbortController. Ge varje sorts förfrågan som kan bli inaktuell en egen controller som byts ut för varje förfrågan, och avbryt allt idisconnectedCallback(). IgnoreraAbortError. - Webbläsaren låter din kod läsa svar från andra origins bara om servern tillåter det med CORS. Felet syns bara i konsolen.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 15.11.1 i kursboken.
- Using the Fetch API, AbortController och Cross-Origin Resource Sharing (CORS) på MDN.
- RFC 9457 Problem Details for HTTP APIs, standarden för felmeddelanden som JSON.
ARCHITECTURE.mdi övningsuppgiften Wikisök, som förklarar varför lösningen har treAbortController.