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.
Synkront och asynkront
Section titled “Synkront och asynkront”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å.
Promises
Section titled “Promises”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>}.
then(), catch() och finally()
Section titled “then(), catch() och finally()”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.
Att skapa ett promise själv
Section titled “Att skapa ett promise själv”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.
async och await
Section titled “async och await”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:
awaitfår bara användas i en funktion markerad medasync, eller direkt på översta nivån i en modul. Glömmer duasyncfår du ettSyntaxError, 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ändaawaitellerthen()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.
Fråga: varför är listan tom?
Section titled “Fråga: varför är listan tom?”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.
Flera promises samtidigt
Section titled “Flera promises samtidigt”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. |
Fråga: hur lång tid tar det?
Section titled “Fråga: hur lång tid tar det?”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.
Glöm inte felen
Section titled “Glöm inte felen”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.') }}Att ta med sig
Section titled “Att ta med sig”- 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()ochfinally()säger vad som ska hända när promiset är klart. Funktionerna körs alltid som microtasks, aldrig direkt.awaitväntar på ett promise och pausar bara sin egen funktion. Fel fångas medtryochcatch. Enasync-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.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 13 i kursboken.
- Using promises och async function på MDN.
- Asynchronous Programming i Eloquent JavaScript, som bygger upp samma idéer från grunden med ett annat exempel.