Hoppa till innehåll

Promises och async/await

Ett nätverksanrop tar tid. Mot en server i närheten kanske 20 millisekunder, över en dålig mobilanslutning flera sekunder, och ibland kommer inget svar alls. Under tiden ska sidan fortsätta att fungera. Den här artikeln handlar om hur JavaScript låter dig vänta på något utan att låsa sidan, och bygger på Händelseloopen från kursvecka 11.

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

Synkron kod körs en sats i taget, och varje sats är klar innan nästa börjar. Det är så du har skrivit det mesta hittills, och det är lätt att följa.

Tänk dig att ett nätverksanrop fungerade likadant:

// Not real code: fetch does not work like this.
const response = fetchSynchronously('https://api.example.com/books')
console.log(response)

Medan den första raden väntar på svaret blir stacken aldrig tom. Precis som med busyWait() i förra veckans artikel kan händelseloopen inte hämta nästa task. Sidan ritas inte om, och klick och tangenttryckningar får vänta. Webbläsarens äldsta API för nätverksanrop, XMLHttpRequest, kan faktiskt göra synkrona anrop, och det avråds från av just det skälet.

Asynkron kod ber i stället webbläsaren göra arbetet och säga till när det är klart. Själva nätverksanropet sköts av webbläsaren, utanför din kods tråd. Stacken blir tom direkt, sidan fortsätter att fungera, och när svaret har kommit läggs koden som ska ta hand om det i kön.

Du har redan sett mönstret. setTimeout() och addEventListener() tar emot en funktion, en callback, som körs senare. Problemet med callbacks är att de blir svåra att läsa så fort ett steg beror på ett annat: hämta en bok, sedan dess författare, sedan författarens andra böcker. Varje steg hamnar en nivå längre in, och felhanteringen måste upprepas på varje nivå.

Ett promise är ett objekt som står för ett värde som inte finns ännu. En funktion som gör något asynkront returnerar ett promise direkt, och fyller i värdet senare. Webbläsarens moderna API:er, som fetch(), fungerar så.

Ett promise befinner sig i ett av tre tillstånd:

  • pending – arbetet pågår,
  • fulfilled – arbetet lyckades, och promiset har ett värde,
  • rejected – arbetet misslyckades, och promiset har ett fel.

Ett promise som är fulfilled eller rejected kallas settled. Det blir det bara en gång och byter aldrig tillstånd igen. Namnen är desamma som i utvecklarverktygen, där ett promise som inte är klart visas som Promise {<pending>}.

Tillståndsdiagram för ett promise. När promiset skapas är det pending. Från pending går det antingen till fulfilled, när det lyckas och får ett värde, eller till rejected, när det misslyckas och får ett fel. Fulfilled och rejected ligger tillsammans i tillståndet settled. then() får värdet från ett fulfilled promise och catch() felet från ett rejected. En anteckning säger att ett settled promise aldrig byter tillstånd igen.

Du säger vad som ska hända när promiset är klart med tre metoder:

fetch('https://api.example.com/books/42')
.then((response) => response.json())
.then((book) => {
console.log(book.title)
})
.catch((error) => {
console.error('Could not load the book.', error)
})
.finally(() => {
spinner.hidden = true
})

then() returnerar ett nytt promise, och det är det som gör att anropen kan kedjas. Returnerar funktionen i then() ett värde blir det nästa then()s värde. Returnerar den ett promise, som response.json() gör, väntar kedjan på det innan den går vidare. Kastas ett fel någonstans hoppar kedjan direkt till närmaste catch(). finally() körs oavsett hur det gick.

Kedjan är platt, och ett enda catch() räcker för alla steg. Det löser båda problemen med callbacks.

Oftast får du promises från ett API. Någon gång behöver du göra ett själv, typiskt för att göra om ett äldre callback-baserat API till ett som returnerar ett promise:

function wait(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds)
})
}
wait(1000).then(() => console.log('One second has passed.'))

Funktionen du ger new Promise() får två funktioner. Anropar du resolve(value) blir promiset fulfilled med värdet, och anropar du reject(error) blir det rejected med felet. Behöver du sällan skapa promises själv är det ett tecken på att du gör rätt.

Fråga: i vilken ordning skrivs bokstäverna ut?

Section titled “Fråga: i vilken ordning skrivs bokstäverna ut?”
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')

Promise.resolve() skapar ett promise som redan är fulfilled.

A, D, C, B.

A och D skrivs ut först, eftersom skriptet körs klart innan något annat. Funktionen i then() körs aldrig direkt, inte ens när promiset redan är fulfilled. Den läggs i kön som en microtask. Som du läste i Händelseloopen töms kön med microtasks så snart den pågående tasken är klar, före nästa task i task queue. Därför kommer C före B, trots att timern startades först.

Att then() alltid är asynkront gör koden förutsägbar: det som står efter then() körs alltid före funktionen i den, oavsett om promiset redan var settled.

Kedjor med then() är bättre än callbacks, men koden läses fortfarande inte uppifrån och ned som synkron kod. Det gör async och await. De bygger på promises och gör samma sak som kedjan ovan:

async function showBook(id) {
try {
const response = await fetch(`https://api.example.com/books/${id}`)
const book = await response.json()
console.log(book.title)
} catch (error) {
console.error('Could not load the book.', error)
} finally {
spinner.hidden = true
}
}

await väntar på att ett promise ska bli klart. Lyckas det blir uttrycket värdet. Misslyckas det kastas felet, och du fångar det med vanliga try, catch och finally.

await pausar bara funktionen den står i, inte sidan. När funktionen når ett await lämnar den stacken, och händelseloopen kan köra annat under tiden. När promiset är klart fortsätter funktionen där den slutade, som en microtask.

Två regler att känna till:

  • await får bara användas i en funktion markerad med async, eller direkt på översta nivån i en modul. Glömmer du async får du ett SyntaxError, som talar om var.
  • En async-funktion returnerar alltid ett promise. Returnerar du ett värde blir promiset fulfilled med det, och kastar du ett fel blir det rejected. Den som anropar funktionen måste alltså själv använda await eller then() för att komma åt resultatet.

Markera bara funktioner som async om de faktiskt använder await. Ordet säger den som läser att funktionen gör något asynkront, och att anropet ger ett promise.

async function getBooks() {
const response = await fetch('https://api.example.com/books')
return response.json()
}
const books = getBooks()
console.log(books.length)

Servern svarar med en array med tre böcker. Vad skrivs ut, och hur rättar du det?

undefined.

getBooks() är async och returnerar ett promise direkt, långt innan svaret har kommit. books är alltså ett promise, och ett promise har ingen egenskap length.

Rätta det med await: const books = await getBooks(). Det kräver i sin tur att koden står i en async-funktion eller på översta nivån i en modul. Det är vanligt att async sprider sig uppåt på det sättet, ända till den lyssnare eller det anrop som startade alltihop.

Tänk dig att du har id:n för tre böcker och vill hämta alla. Den naturliga första versionen är en loop:

const books = []
for (const id of ids) {
books.push(await getBook(id))
}

Koden fungerar, men hämtar böckerna en i taget. Varje await väntar in sitt svar innan nästa anrop ens startar, fast anropen inte beror på varandra. Tar varje anrop en halv sekund tar tre böcker en och en halv sekund.

Starta i stället alla anrop först och vänta sedan på alla på en gång med Promise.all():

const books = await Promise.all(ids.map((id) => getBook(id)))

ids.map() anropar getBook() för varje id direkt, och får tillbaka en array med promises. Alla anrop pågår samtidigt. Promise.all() ger ett promise som blir fulfilled när alla är klara, med en array av resultaten i samma ordning som id:na. Tre böcker tar nu ungefär lika lång tid som en.

Promise.all() blir rejected så fort ett av promisen misslyckas. Ska ett misslyckat anrop inte förstöra de andra finns det fler varianter:

Metod Klart när Resultat
Promise.all() alla har lyckats, eller ett misslyckas En array med alla värden, eller det första felet.
Promise.allSettled() alla är settled En array med { status, value } eller { status, reason }.
Promise.any() det första lyckas Det första värdet.
Promise.race() det första är settled Det första värdet eller felet.
async function first() {
await wait(1000)
await wait(1000)
}
async function second() {
const a = wait(1000)
const b = wait(1000)
await a
await b
}

wait() är funktionen från avsnittet om att skapa promises. Ungefär hur lång tid tar ett anrop till first() respektive second()?

first() tar ungefär två sekunder och second() ungefär en.

I first() startar den andra timern först när den första är klar. I second() startar båda timrarna direkt, när wait() anropas, och båda räknar samtidigt. När await a är klar efter en sekund är b också nästan klar.

Det är alltså anropet som startar arbetet, inte await. await bara väntar. Promise.all() bygger på samma sak, och är dessutom tydligare och hanterar fel bättre än två await efter varandra: misslyckas b innan a är klar i second() har ingen ännu börjat vänta på b, och webbläsaren rapporterar felet som ofångat.

Ett promise som blir rejected utan att någon fångar felet ger meddelandet Uncaught (in promise) i konsolen. Användaren ser ingenting, och sidan står kvar som om den väntar. Varje kedja av asynkrona anrop behöver alltså ett ställe där felet tas om hand, och visas för användaren om det påverkar vad användaren kan göra.

Det gäller särskilt i händelselyssnare. Webbläsaren bryr sig inte om vad en lyssnare returnerar, så när lyssnaren är en async-funktion tar ingen hand om det promise den returnerar. Lyssnaren gör lite och en metod gör jobbet, som i Händelser i webbkomponenter, och den metoden fångar sina egna fel:

this.#form.addEventListener('submit', (event) => {
event.preventDefault()
this.#search(this.#input.value)
}, { signal })
async #search(term) {
try {
const books = await this.#fetchBooks(term)
this.#showBooks(books)
} catch {
this.#showError('The search failed. Please try again.')
}
}
  • Asynkron kod ber webbläsaren göra arbetet och fortsätter sedan. Stacken blir tom och sidan fortsätter att fungera.
  • Ett promise är pending tills det blir settled: fulfilled med ett värde eller rejected med ett fel. Det blir settled bara en gång.
  • then(), catch() och finally() säger vad som ska hända när promiset är klart. Funktionerna körs alltid som microtasks, aldrig direkt.
  • await väntar på ett promise och pausar bara sin egen funktion. Fel fångas med try och catch. En async-funktion returnerar alltid ett promise.
  • Det är anropet som startar arbetet. Starta oberoende anrop samtidigt och vänta på alla med Promise.all().
  • Varje asynkron kedja behöver ett ställe där felen tas om hand.
CCBY