Händelseloopen
Fram till nu har dina program startat, kört uppifrån och ned och sedan tagit slut. I webbläsaren är det tvärtom. Det mesta av din kod körs inte när sidan laddas, utan när användaren klickar, skriver eller drar med musen. Programmet väntar, och det är händelserna som bestämmer vilken kod som körs och när.
Den här artikeln handlar om maskineriet bakom: hur webbläsaren kör din kod med en tråd, en stack, en kö och en loop. Nästa artikel visar hur du lyssnar på händelser i DOM:en, och den sista hur webbkomponenter tar emot och skickar händelser.
Tidsåtgång: Ungefär 15 minuter.
Några korta frågor finns insprängda i texten. Försök svara innan du klickar fram lösningen.
En tråd för både koden och sidan
Section titled “En tråd för både koden och sidan”I kursvecka 8 såg du att HTML-tolken och din JavaScript-kod delar på samma tråd, webbläsarens huvudtråd. Det gäller mer än tolkningen. På huvudtråden körs också allt annat som har med sidan att göra: stilberäkning, layout, utritning och hanteringen av klick och tangenttryckningar.
En tråd kan bara göra en sak i taget. Medan din kod körs står allt det andra still. Det är grunden för allt i den här artikeln, och förklaringen till varför JavaScript ibland kallas entrådat.
Anropsstacken
Section titled “Anropsstacken”För att hålla reda på vilken kod som körs just nu använder JavaScript-motorn en anropsstack (call stack). Varje funktionsanrop lägger en ram (stack frame) överst på stacken, med funktionens argument och lokala variabler. När funktionen returnerar tas ramen bort.
function square(x) { return x * x}
function sumOfSquares(a, b) { return square(a) + square(b)}
console.log(sumOfSquares(3, 4))När square(3) körs ligger tre ramar på stacken: överst square, under den sumOfSquares och
längst ned skriptet självt. Stacken är en LIFO-struktur, last in, first out: den ram som kom sist tas
bort först. Det är den här kedjan du ser i konsolen som en stack trace när ett fel inte fångas.
Objekt, arrayer och funktioner ligger inte på stacken utan på heapen, ett större minnesområde. Stacken håller bara referenser dit. När inget längre refererar till ett objekt tar webbläsarens skräpsamlare (garbage collector) bort det. Det behöver du sällan tänka på, med ett undantag som du möter i den sista artikeln: en händelselyssnare är en referens, och den kan hålla kvar ett objekt som annars hade städats bort.
Kön och loopen
Section titled “Kön och loopen”När skriptet har körts klart är stacken tom. Men programmet är inte slut, eftersom webbläsaren har en task queue, en kö med tasks (uppgifter). Där hamnar det som ska köras härnäst:
- en händelse, som att användaren klickar på en knapp som har en lyssnare,
- en timer som har gått ut,
- ett svar från nätverket,
- ett meddelande från en web worker.
Händelseloopen (the event loop) binder ihop kön och stacken. Den gör samma sak om och om igen: den tar den äldsta tasken ur kön och kör den tills stacken är tom. Däremellan kan webbläsaren rita om sidan. Sedan tar den nästa task.
En task körs alltid klart innan nästa påbörjas. Den avbryts aldrig halvvägs av en annan task, så två händelselyssnare kan aldrig köras samtidigt. Det kallas på engelska run to completion och gör koden lättare att resonera om: under tiden din funktion körs ändras ingenting under fötterna på den.
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)
console.log('C')A, C, B.
setTimeout() kör inte funktionen. Den ber webbläsaren lägga funktionen i kön när tiden har gått,
här efter 0 millisekunder. Men funktionen i kön får vänta tills stacken är tom, och stacken är inte
tom förrän hela skriptet har körts, alltså efter att C har skrivits ut.
En fördröjning på 0 betyder alltså inte “nu”, utan “så snart som möjligt efter att den kod som körs just nu är klar”.
När sidan fryser
Section titled “När sidan fryser”Eftersom allt delar på samma tråd kan din kod låsa hela sidan. Den här funktionen räknar i tre sekunder:
function busyWait(milliseconds) { const end = Date.now() + milliseconds while (Date.now() < end) { // busy-waiting }}
button.addEventListener('click', () => { busyWait(3000) status.textContent = 'Klar!'})Under de tre sekunderna blir stacken aldrig tom. Händelseloopen kan inte hämta nästa task, och sidan ritas inte om. Markören i ett textfält slutar blinka, animationer stannar och knappar reagerar inte. Efter en stund kan webbläsaren fråga om du vill avbryta sidan.
Därför finns det ingen sleep() i JavaScript i webbläsaren, som i många andra språk. En funktion som
väntar på det sättet skulle låsa sidan på samma sätt. Ska något hända senare, ber du om det med en
timer eller en händelse, och låter stacken bli tom under tiden.
Fråga: försvinner klicket?
Section titled “Fråga: försvinner klicket?”Medan busyWait(3000) körs klickar användaren på en annan knapp, som också har en lyssnare.
Försvinner klicket, eller händer något när de tre sekunderna har gått?
Klicket försvinner inte. Webbläsaren registrerar det och lägger en task i kön, men tasken får
vänta tills stacken är tom. När busyWait() är klar och lyssnaren har returnerat kör händelseloopen
nästa task, och den andra knappens lyssnare körs.
För användaren ser det ut som att sidan först inte reagerar alls, och sedan gör allt på en gång. Det är ett säkert tecken på kod som blockerar huvudtråden.
Timers
Section titled “Timers”setTimeout() lägger en funktion i kön när en viss tid har gått, och setInterval() gör det
upprepat. Båda returnerar ett id som du använder för att stoppa dem:
const timeoutId = setTimeout(() => { console.log('At least three seconds have passed.')}, 3000)
clearTimeout(timeoutId)
const intervalId = setInterval(() => { console.log('Roughly one more second has passed.')}, 1000)
clearInterval(intervalId)Tiden är ett minimum, inte ett löfte. Funktionen läggs i kön när tiden har gått, och får sedan vänta på sin tur. Är huvudtråden upptagen dröjer det längre. Webbläsaren kan också själv sakta ned timers, till exempel i en flik som inte syns.
En timer fortsätter tills den stoppas. Ett intervall som en komponent startar fortsätter alltså att
köras även när komponenten har tagits bort från sidan, om ingen anropar clearInterval(). Det är ett
vanligt fel, och du ser i den sista artikeln var komponenten ska stoppa sina timers.
Fråga: köas anropen?
Section titled “Fråga: köas anropen?”Funktionen i ett intervall på 50 millisekunder tar 200 millisekunder att köra. Hamnar det då flera anrop i kön som sedan körs direkt efter varandra?
Nej. Enligt standarden schemaläggs nästa anrop av ett intervall först när det föregående är klart, så det ligger aldrig mer än ett anrop och väntar. Hur lång pausen blir mellan anropen skiljer sig något mellan webbläsarna.
Problemet är ett annat: om funktionen tar 200 millisekunder och pausen är kort är huvudtråden upptagen nästan hela tiden. Sidan hinner sällan ritas om och känns trög. Går det inte att göra funktionen snabbare hör arbetet hemma i en web worker.
Microtasks
Section titled “Microtasks”Bredvid task queue finns en kö för microtasks. Där hamnar bland annat det som ska hända när ett
promise blir klart. Den töms helt efter varje task, innan webbläsaren ritar om sidan eller tar nästa
task ur den vanliga kön. Promises och async/await är ämnet för kursvecka
12. Här räcker det att du vet att kön finns.
Web workers
Section titled “Web workers”Har du arbete som tar lång tid, som att räkna fram primtal eller bearbeta en stor bild, kan du flytta det till en web worker. En worker kör JavaScript i en egen tråd, med en egen stack, en egen kö och en egen händelseloop. Huvudtråden förblir ledig under tiden.
En worker har ingen åtkomst till DOM:en. Den och huvudtråden kommunicerar bara genom att skicka
meddelanden till varandra med postMessage() och lyssna på händelsen message:
let n = 1
while (true) { n++ if (isPrime(n)) { postMessage(n) }}
function isPrime(n) { for (let i = 2; i <= Math.sqrt(n); i++) { if (n % i === 0) { return false } } return true}const worker = new Worker(new URL('./prime-worker.js', import.meta.url), { type: 'module'})
worker.addEventListener('message', (event) => { output.textContent = event.data})Den oändliga loopen hade låst sidan på huvudtråden. I en worker gör den det inte, och svaren kommer
till huvudtråden som händelser i kön, precis som ett klick. new URL(..., import.meta.url) känner du
igen från kursvecka 10. Vite
känner igen mönstret och tar med workerns fil i bygget.
Kursens övningsuppgifter behöver inga workers, men det är bra att veta att möjligheten finns när huvudtråden inte räcker till.
Att ta med sig
Section titled “Att ta med sig”- Din kod, tolkningen av HTML, layouten och utritningen delar på en tråd, huvudtråden. Medan din kod körs händer inget annat.
- Anropsstacken håller reda på funktionsanropen. Task queue håller det som ska köras härnäst: händelser, timers och nätverkssvar.
- Händelseloopen tar en task i taget ur kön och kör den tills stacken är tom. Däremellan kan sidan ritas om.
- Kod som tar lång tid låser sidan. Det finns ingen
sleep(); använd timers och händelser i stället, och web workers för tungt arbete. - En timer är ett minimum, inte ett löfte, och fortsätter tills den stoppas med
clearTimeout()ellerclearInterval().
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan. Där finns också två föreläsningar om händelseloopen, av Philip Roberts och Jake Archibald, som är väl värda att se.
- JavaScript execution model på MDN.
- Using Web Workers på MDN och Web Workers i Vites dokumentation.