Hoppa till innehåll

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.

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.

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.

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.

Aktivitetsdiagram över händelseloopen. I en loop som pågår så länge fliken är öppen tar webbläsaren den äldsta tasken ur kön, till exempel ett skript, en händelse, en timer eller ett nätverkssvar, och kör den tills anropsstacken är tom. Sedan töms kön med microtasks, som promises. Är det dags att rita om sidan räknas stil och layout ut och sidan ritas om. En anteckning säger att medan en task körs händer inget annat på huvudtråden: sidan ritas inte om och nya händelser får vänta i kön.

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”.

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.

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.

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.

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.

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.

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:

js/prime-worker.js
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
}
js/index.js
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.

  • 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() eller clearInterval().
CCBY