Verständnis von JavaScript-Closures und dem Event-Loop
JavaScript-Closures halten externe Variablen am Leben; der Event-Loop ordnet den Call-Stack, Microtasks und Macrotasks. Beides wird anhand von ausführbaren Codebeispielen erklärt.
Quellen: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion
JavaScript wird oft als eine Sprache beschrieben, die voller Besonderheiten ist, doch zwei ihrer am meisten missverstandenen Eigenschaften – Schließfunktionen und der Event-Loop – stammen tatsächlich von derselben Grundidee: Funktionen sowie geplante Aufgaben verschwinden nicht einfach, nur weil der umliegende Code weitergeführt wird. Um zu verstehen, wie eine Funktion Variablen auch nach dem Rückgang ihres Elternobjekts „merken“ kann und wie der Laufzeitumgebung entschieden wird, wann Code ausgeführt werden soll, der nicht sofort bereit ist, muss man aufhören, JavaScript als einfaches, zeilenweises Skript zu betrachten, und anfangen, es als ein System mit Speicherverwaltung und Zeitregeln zu sehen. Dieser Artikel erläutert beide Konzepte, beginnend mit Schließfunktionen.
Verständnis von Schließfunktionen
Was eine Schließfunktion eigentlich ist
Eine Schließung tritt auf, wenn eine Funktion Variablen aus dem Scope behält, in dem sie erstellt wurde, und weiterhin auf sie zugreifen kann, selbst nachdem die übergeordnete Funktion bereits abgeschlossen wurde.
Eine nützliche Vorstellung davon ist, dass eine Funktion überallhin ein kleines „Rucksäckchen“ mit Variablen mitnimmt.
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Beachten Sie, dass createCounter() ausgeführt und zurückgegeben wird, doch die darin definierte innere Funktion hat weiterhin Zugriff auf count. Dieser anhaltende Zugriff, auch nachdem der Aufruf der übergeordneten Funktion technisch abgeschlossen ist, ist die Schließung.
Typische Situationen, in denen Schließungen auftreten
Schließungen sind wichtig, wenn eine Funktion Daten behalten muss, nachdem die Funktion, die sie erstellt hat, bereits beendet wurde. Dieses Muster kommt in verschiedenen alltäglichen Szenarien vor:
- Private Variablen – Daten werden versteckt, damit externer Code nicht direkt darauf zugreifen kann.
setTimeout oder setInterval.Warum sie wichtig sind
Closures ermöglichen es Ihnen, Zustände zu speichern, ohne auf globale Variablen zurückzugreifen, was Ihren Code sauberer und sicherer macht.
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
},
getBalance() {
return balance;
}
};
}
const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined
In diesem Beispiel kann balance nicht direkt von außerhalb der Funktion erreicht werden. Nur die Methoden des Objekts, die von createAccount zurückgegeben werden, haben darauf Zugriff.
Diese Struktur bringt Ihnen einige echte Vorteile:
- Bessere Kapselung
- Echter privater Zustand
- Einfachere, leichter lesbare Code
- Geringerer Bedarf an globalen Variablen
Das klassische Schleifenbeispiel
Eine gängige Demonstration von Closures in Aktion ist der Unterschied zwischen dem Einsatz von var und let innerhalb einer Schleife, die Callbacks auslöst.
So verhält es sich mit var:
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 4
// 4
// 4
Die Ausgabe entspricht zunächst nicht dem, was viele Menschen erwarten, und der Grund dafür ist, dass var funktionsbasiert statt blockbasiert skopiert ist. Alle drei setTimeout-Callbacks beziehen sich letztendlich auf dieselbe Variable i. Wenn einer von ihnen tatsächlich ausgeführt wird, ist die Schleife bereits abgeschlossen und i hat bereits den Wert 4 angenommen.
Vergleichen Sie das nun mit let:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 1
// 2
// 3
Weil let blockbasiert gebunden ist, erhält jeder Durchlauf der Schleife eine eigene, unabhängige Bindung von i. Das bedeutet, dass jeder Callback einen anderen Wert verwendet, wodurch die Zahlen in der von Ihnen erwarteten Reihenfolge ausgegeben werden.
Eine häufige Interviewfrage
Frage: Was ist eine Schließung in JavaScript?
Eine Schließung ist eine Funktion, die sich an Variablen aus dem Scope erinnert, in dem sie definiert wurde, und weiterhin auf diese zugreifen kann – selbst nachdem die äußere Funktion bereits ausgeführt wurde.
Nachfrage: Wo würden Sie Schließungen tatsächlich einsetzen?
Eine gute Antwort bezieht sich auf praktische Anwendungen statt nur auf die Definition:
Closures kommen am häufigsten in Callbacks, Timern, Event-Handlern und Funktionsfabriken vor. Sie sind auch nützlich, wenn ein Zustandswert privat gehalten werden soll anstelle im globalen Scope zu existieren.
Zusammenfassung
Die Kernidee lässt sich auf eine Gleichung reduzieren: Eine Funktion zusammen mit den umgebenden Variablen ergibt eine Closure. Wenn man sich das Bild eines Rucksacks vorstellt – eine Funktion, die ihre mitgeführten Variablen überallhin mitnimmt, wo sie aufgerufen wird – wird das ganze Konzept deutlich weniger rätselhaft.
Das Event-Loop-Prinzip verstehen
Closures erklären, wie eine Funktion Daten über Zeit hinweg speichern kann. Das Event-Loop erklärt hingegen, wie JavaScript entscheidet, wann Code ausgeführt werden soll, der nicht sofort bereit ist – und genau daraus entstehen viele verwirrende Ausgaben in der Konsole.
Fast jeder JavaScript-Entwickler stößt irgendwann auf eine Variante dieses genauen Codeausschnitts – sei es im Rahmen eines Interviews oder zufällig:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
Wenn man diesen Code von oben nach unten liest, würde man erwarten, dass die Konsole zunächst 1, dann 2 und schließlich 3 ausgibt – der Timeout ist auf null Millisekunden gesetzt, sodass es eigentlich keinerlei Verzögerung geben sollte.
Doch wenn man ihn in der Browser-Konsole oder unter Node ausführt, sieht die tatsächliche Ausgabe so aus:
1
3
2
Warum wird also Code, der „sofort“ ausgeführt werden soll, tatsächlich *nach* Code ausgeführt, der später im Dateiinhalt steht?
Um diese Frage zu beantworten, muss man den Mechanismus verstehen, der hinter dem Code abläuft: den Event-Loop. Anstatt sich in formelhaftem Vokabular zu Verarbeitungskontexten und Scheduling-Algorithmen zu verlieren, hilft es, sich ein einfaches, nachhaltiges mentales Modell davon zu erstellen, was tatsächlich geschieht.
Der zentrale Widerspruch im Kern von JavaScript
Vor Diagrammen und Definitionen gibt es eine Tatsache, die Sie sich unbedingt einprägen müssen:
JavaScript ist streng gesprochen eingleisig.
Eingleisig zu sein bedeutet, dass JavaScript mit genau einem Aufrufstapel arbeitet. Es verfügt über einen einzigen Verarbeitungsbereich und kann zu jedem Zeitpunkt nur eine Codezeile ausführen. Es kann keine zwei Funktionen parallel laufen lassen und auch nicht selbst eine Berechnung auf mehrere Kerne verteilen.
Diese Einschränkung wirft eine offensichtliche Frage auf. Stellen Sie sich einen Codeausschnitt vor, der drei volle Sekunden warten muss, bis eine langsame API antwortet – mit nur einem verfügbaren Thread und ohne weitere Unterstützung: Was kann der Rest der Seite in dieser Wartezeit tun?
In einer rein einthreadigen Umgebung ohne jegliche Unterstützung würde der gesamte Browser blockieren. Man könnte weder auf etwas klicken, scrollen, in ein Feld tippen noch eine Animation abspielen – alles würde einfrieren, bis endlich die Netzwerkanfrage beantwortet wird.
Doch das ist nicht das, was tatsächlich passiert. Moderne Webanwendungen streamen regelmäßig Video, senden Hintergrundnetzanfragen, aktualisieren stille Analysedaten und nehmen Tasteneingaben gleichzeitig entgegen, ohne dabei zu blockieren.
Wie schafft es also eine einthreadige Sprache, das zu bewältigen?
Die Antwort: JavaScript selbst ist einthreadig, doch die Umgebung darum herum – der Browser oder Node.js – nicht.
Ein Restaurantküchen-Modell als Gedankenmodell
Die klarste Art, diese Architektur zu verstehen, besteht darin, sich eine geschäftige Fast-Food-Küche vorzustellen:
[ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
│
▼ Hands off slow jobs
[ The Kitchen Staff ] (Web APIs)
│
▼ Ready order slips
[ Order Counter ] (Queues)
▲
│ Checks constantly
[ The Floor Manager ] (The Event Loop)
So verteilen sich die Rollen:
- Der Solo-Koch (der Call Stack). Schnell und geschickt, kann aber gleichzeitig nur an einer Bestellung arbeiten – er muss diese vollständig beenden, bevor er mit der nächsten beginnt.
- Die Küchenhelfer (Web APIs). Wenn eine Bestellung lange warten muss – aufgrund eines Timers oder einer langsamen Antwort des Lieferfahrers – steht der Koch nicht untätig herum und starrt auf die Uhr. Stattdessen gibt er den Auftrag an einen Helfer weiter mit der Anweisung, darauf zu achten und bei Fertigstellung Bericht zu erstatten, während der Koch sich wieder seiner nächsten Aufgabe widmet.
- Die Bestelltheke (die Callback- und Microtask-Warteschlangen). Sobald der Timer abgelaufen ist, unterbricht der Helfer den Koch nicht mitten in seiner Arbeit, indem er ihm die Bestellung in die Hände drückt – das würde Chaos verursachen. Stattdessen wird die fertige Bestellung auf die Theke gelegt, um dort zu warten.
Das ist das gesamte System in Miniaturform. Wenn man sich das Restaurant vorstellt, ist es an der Zeit, diese Rollen durch ihre tatsächlichen Namen im JavaScript-Engine zu ersetzen.
Die Engine benennt diese vier Komponenten unterschiedlich, doch jede entspricht direkt einer Rolle im Restaurant.
Das Diagramm unten zeigt, wie die Laufzeitumgebung (Browser oder Node.js) die JavaScript-Engine umschließt und diese Komponenten miteinander verbindet.
┌───────────────────────────────────────────────────────────┐
│ BROWSER / RUNTIME │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ CALL STACK │ │ WEB APIs │ │
│ │ (JS Engine/V8) │ │ │ │
│ │ │ ──────────> │ setTimeout() │ │
│ │ fnA() │ │ fetch() │ │
│ │ console.log() │ │ DOM Events │ │
│ └────────┬────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ │ Finished │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ MICROTASK QUEUE │ │
│ │ │ (Promises, VIP) │ │
│ │ └─────────┬─────────┘ │
│ │ │ │
│ EVENT LOOP ▼ │
│ "Is Stack empty?" ┌───────────────────┐ │
│ └───────────────────── │ MACROTASK QUEUE │ │
│ │ (Timers, Clicks) │ │
│ └───────────────────┘ │
└───────────────────────────────────────────────────────────┘
Der Aufrufstapel – „was gerade passiert“
Der Aufrufstapel ist eine gewöhnliche LIFO-Struktur (Letzter Eintrag, Erster Ausgang), die vom JavaScript-Engine selbst verwaltet wird – es handelt sich dabei um denselben Mechanismus, der in V8 in Chrome und Node oder in JavaScriptCore in Safari verwendet wird.
Jedes Mal, wenn Sie eine Funktion aufrufen, wird sie an die Spitze dieses Stacks gelegt. Sobald diese Funktion zurückgegeben hat oder mit ihrer Ausführung fertig ist, wird sie von der Spitze entfernt.
function stepThree() {
console.log("Done!");
}
function stepTwo() {
stepThree();
}
function stepOne() {
stepTwo();
}
stepOne();
Das Nachverfolgen einer kleinen Kette solcher Aufrufe zeigt die Mechanik klar:
stepOnewird auf den Stack gelegt.stepOneruftstepTwoauf, wodurchstepTwoan die Spitze des Stacks gelegt wird.stepTworuftstepThreeauf, wodurchstepThreenoch weiter an die Spitze des Stacks gelegt wird.stepThreeführtconsole.logaus, was „Done!“ ausgibt, und wird anschließend vom Stack entfernt.
stepThree ist abgeschlossen, wodurch es aus dem Stack entfernt wird.stepTwo ist abgeschlossen, wodurch es aus dem Stack entfernt wird.stepOne ist abgeschlossen, wodurch es aus dem Stack entfernt wird.Falls eine der Funktionen in diesem Stack beispielsweise zehn Sekunden lang intensive Berechnungen durchführen muss, friert die gesamte Ausführung für diese zehn Sekunden ein. Nichts anderes kann laufen, bis der Stack geleert ist – das ist die direkte Folge davon, dass der „Koch“ nur zwei Hände hat.
Web APIs – wo das „Warten“ tatsächlich stattfindet
Etwas, das viele Menschen zu Beginn ihrer Karriere überrascht: setTimeout, fetch sowie DOM-Event-Listener sind keine Eigenschaften der JavaScript-Sprache selbst.
Man findet sie nirgends in der ECMAScript-Spezifikation. Sie werden von der Umgebung bereitgestellt, die Ihren Code ausführt – dem Browser oder, im Fall von Node.js, einer Kombination aus C++-Bindings und der libuv-Bibliothek.
Betrachten Sie diesen Aufruf:
setTimeout(callback, 2000);
Wenn der Aufrufstapel diese setTimeout-Aufruf ausführt, tut er im Grunde nur Folgendes: Er weist das interne Timer-System des Browsers an, mit dem Zählen von 2000 Millisekunden zu beginnen. Sobald diese Anweisung weitergeleitet wurde, wird setTimeout selbst sofort aus dem Aufrufstapel entfernt, und die Ausführung setzt mit dem nächsten Teil Ihres Codes fort. Das Zählen selbst findet vollständig außerhalb des einzigen Threads von JavaScript, also in der Host-Umgebung, statt.
Die Makrotask-Warteschlange – die reguläre Warteschlange
Diese Warteschlange wird je nach Ansprechpartner auch als Callback-Warteschlange oder Task-Warteschlange bezeichnet.
Sobald eine Web API ihre Hintergrundaufgabe abgeschlossen hat – ein Timer auf Null kommt, eine Netzwerkanfrage vollständig eingetroffen ist oder ein Benutzer etwas klickt – platziert die Hostumgebung den zugehörigen Callback in diese Warteschlange.
Alles, was sich in dieser Warteschlange befindet, verhält sich ordentlich: Es handelt sich um eine standardmäßige FIFO-Warteschlange (First In, First Out), wobei jeder Eintrag darauf wartet, auf dem Call Stack ausgeführt zu werden.
Typische Beispiele für Makrotasks sind:
- Callbacks, die mit
setTimeout()undsetInterval()geplant werden - DOM-Event-Listener wie
click,scrolloderkeydown setImmediate(), das speziell für Node.js ist
Die Microtask-Warteschlange – der Express-Channel
Neben dieser regulären Warteschlange gibt es eine kleinere Warteschlange mit weitaus höherer Priorität: die Microtask-Warteschlange.
Alles, was hier platziert wird, rückt effektiv an die Spitze der Warteschlange. Sobald der Call Stack leer ist, schaut der Event Loop zuerst in die Microtask Queue – noch bevor er prüft, was in der Macrotask Queue wartet.
Zu den Elementen, die in die Microtask Queue gelangen, gehören:
- Handler, die über
.then(),.catch()oder.finally()an Promises angehängt werden - Der Code, der nach einem
awaitinnerhalb einerasync-Funktion ausgeführt wird - Alles, was mit
queueMicrotask()geplant wurde - Callbacks, die von einem
MutationObserverausgelöst werden
Die zugrunde liegende Regel des Loops
Wenn man die Metaphern weglässt, ist der Event Loop im Grunde nur eine endlose while-Schleife, die innerhalb der Host-Umgebung läuft und immer wieder fünf strenge Schritte durchführt:
- Entleeren Sie den Aufrufstapel. Setzen Sie die Ausführung des synchronen Codes fort, bis auf dem Stapel absolut nichts mehr übrig ist.
- Räumen Sie die Mikrotask-Warteschlange vollständig leer. Solange eine Mikrotask wartet, führen Sie sie aus. Wenn ihre Ausführung wiederum eine weitere Mikrotask erzeugt, führen Sie auch diese aus. Der Zyklus geht von diesem Schritt erst weiter, wenn in der Mikrotask-Warteschlange nichts mehr vorhanden ist.
- Bearbeiten Sie das Rendering, wenn Sie sich in einem Browser befinden. Der Engine prüft, ob es Zeit ist, den Bildschirm neu zu zeichnen – normalerweise etwa 60 Mal pro Sekunde, also alle ca. 16,6 Millisekunden – und führt bei Bedarf eine Neuberechnung des Layouts sowie eine erneute Zeichnung durch.
- Führen Sie genau eine Makrotask aus. Schauen Sie sich die Makrotask-Warteschlange an, nehmen Sie die Task heraus, die am längsten gewartet hat, legen Sie sie auf den Aufrufstapel und lassen Sie sie bis zum Ende ausführen.
- Starten Sie den Zyklus erneut vom ersten Schritt an.
Der Punkt, der die Leute verwirrt, ist diese Asymmetrie: Die Mikroaufgaben werden vollständig abgearbeitet, jede einzelne, bevor der Loop weitergeht – während Makroaufgaben streng nacheinander bearbeitet werden, wobei zwischen jeder davon eine vollständige Überprüfung des Stacks sowie der Mikroaufgabenschlange stattfindet.
Eine bekannte Interview-Frage im Detail
Nun, da die Mechanismen klar sind, lohnt es sich, einen Codeausschnitt zu betrachten, der viele Entwickler in die Irre führt:
console.log("Start");
setTimeout(() => {
console.log("Timeout Callback");
}, 0);
Promise.resolve().then(() => {
console.log("Promise Callback");
});
console.log("End");
Wenn man diesen Code Zeile für Zeile durch die vier Komponenten geht, wird das überraschende Ergebnis verständlich.
Zuerst die synchrone Ausführung
console.log("Start") wird auf den Stack gelegt, gibt „Start“ aus und wird anschließend wieder abgerufen.
Danach wird setTimeout(...) auf den Stack gelegt. Es weist die Web API-Schicht des Browsers an, einen Timer mit einer Verzögerung von 0 Millisekunden zusammen mit dem angegebenen Callback zu starten. Da die Verzögerung null ist, läuft dieser Timer im Hintergrund praktisch sofort ab, wodurch der Browser umgehend () => console.log("Timeout Callback") in die Macrotask-Queue einfügt. Der setTimeout-Aufruf selbst wird anschließend vom Stack entfernt.
Daraufhin wird Promise.resolve().then(...) auf den Stack gelegt. Die Promise löst sich sofort auf, und ihr .then()-Handler – () => console.log("Promise Callback") – wird direkt in die Microtask-Queue eingefügt. Auch dieser Aufruf wird danach vom Stack entfernt.
Zuletzt wird console.log("End") auf den Stack gelegt, es wird „End“ ausgegeben, und anschließend wird der Aufruf vom Stack entfernt.
Sobald der Stack leer ist
Zu diesem Zeitpunkt bleibt nichts mehr auf dem Aufrufstapel übrig. Der Ereigniszyklus wird aktiv und überprüft seine beiden Warteschlangen: Die Microtask-Warteschlange enthält den Promise-Callback, und die Macrotask-Warteschlange enthält den Timeout-Callback.
Microtasks zuerst
Der Zyklus prüft zunächst die Microtask-Warteschlange. Er findet den Promise-Callback, legt ihn auf den Stapel und es wird „Promise Callback“ ausgegeben. Der Stapel leert sich erneut, und nun ist auch die Microtask-Warteschlange leer.
Dann eine Macrotask
Da keine Microtasks mehr vorhanden sind, wendet sich der Zyklus schließlich der Macrotask-Warteschlange zu. Er nimmt den ältesten Eintrag – den Timeout-Callback – legt ihn auf den Stapel, gibt „Timeout Callback“ aus und entfernt ihn wieder.
Zusammenfassend sieht die endgültige Ausgabefolge wie folgt aus:
Start
End
Promise Callback
Timeout Callback
Auch wenn die Zeitüberschreitung auf 0 Millisekunden festgelegt und vor der Promise im Code definiert wurde, wurde die Callback-Funktion der Promise zuerst ausgeführt. Dies bestätigt die Regel, dass Microtasks immer vor Macrotasks abgearbeitet werden, unabhängig davon, wie kurz die Verzögerung für einen Timer ist.
Dies führt zu einem häufigen Missverständnis darüber, was ein Verzögerungswert tatsächlich verspricht. Viele Anfänger gehen davon aus, dass setTimeout(fn, 1000) bedeutet „führe diese Funktion genau 1.000 Millisekunden von jetzt an aus“. Das ist jedoch nicht der Fall. Tatsächlich garantiert dieser Aufruf lediglich, dass der Callback erst dann in die Aufgabenwarteschlange aufgenommen wird, wenn mindestens 1.000 Millisekunden vergangen sind. Sobald er in der Warteschlange ist, muss er weiter warten, bis der Aufrufstapel leer ist und alle ausstehenden Microtasks abgearbeitet wurden.
Falls Ihre Aufrufstapel fünf Sekunden lang belegt sind, weil eine aufwändige Sortierung ausgeführt oder ein Bild verarbeitet wird, bleibt ein setTimeout(fn, 100)-Callback die gesamten fünf Sekunden lang untätig in der Warteschlange, bevor der Event-Loop überhaupt darauf schauen kann. Kurz gesagt ist eine Timeout-Zeit eine Mindestwartezeit, niemals eine garantierte Ausführungszeit.
Dieses Verhalten, bei dem die Warteschlange geleert wird, hat einen noch gefährlicheren Nebeneffekt, den man für sich allein verstehen sollte. Da der Event-Loop nicht fortfahren will, solange die Microtask-Warteschlange noch nicht vollständig leer ist, ist es möglich, Ihre Anwendung lahmzulegen, ohne dabei technisch gesehen den synchronen Aufrufstapel zu blockieren. Dieser Fehlermodus wird als Microtask-Starvation bezeichnet, manchmal auch als Warteschlangen-Starvation.
Stellen Sie sich eine Funktion wie diese vor:
function infiniteMicrotasks() {
Promise.resolve().then(() => {
infiniteMicrotasks();
});
}
infiniteMicrotasks();
Wenn man sich ansieht, was passiert: Der ursprüngliche Aufruf wird abgeschlossen und der Stack leert sich. Anschließend prüft der Ereignisloop die Microtask-Queue und findet einen Eintrag. Er führt diesen Eintrag aus, doch dadurch wird sofort eine weitere Microtask geplant. Der Ereignisloop prüft die Queue erneut, stellt fest, dass sie weiterhin belegt ist, und führt den nächsten Eintrag aus – was wiederum eine weitere Microtask auslöst. Der Stack leert sich nach jeder Microtask, doch der Ereignisloop verlässt diesen Prüfschritt niemals. Er kommt nie weit genug voran, um es dem Browser zu ermöglichen, die Benutzeroberfläche neu darzustellen oder auf Elemente zuzugreifen, die in der Macrotask-Queue warten.
Die praktische Folge ist, dass die Seite völlig eingefroren erscheint. Klicks werden ignoriert, Animationen stoppen ihre Fortsetzung, und schließlich kann der Browser die Tab-Seite mit einer Warnung wegen eines nicht reagierenden Skripts kennzeichnen.
Zum Zusammenfassen der Art und Weise, wie jede Arbeitsart während der Ausführung geplant und priorisiert wird, hilft es, einen Vergleich der Aufteilung anzusehen, in der sich synchroner Code, Microtasks, Macrotasks sowie das Rendering im Laufzyklus befinden, wie im beigefügten Diagramm dargestellt.
Das Verständnis dafür, wie der Event-Loop funktioniert, ist nicht nur nützliches Wissen für Interviews – es prägt direkt die Art und Weise, wie man reale Anwendungen entwerfen sollte, und daraus ergeben sich drei praktische Gewohnheiten.
Zunächst sollten teure synchrone Schleifen aufgeteilt werden. Wenn Sie beispielsweise ein Array mit 500.000 Einträgen im Hauptthread verarbeiten müssen, vermeiden Sie es, dies in einer durchgehenden for-Schleife zu tun, da dadurch die Benutzeroberfläche einfrieren würde. Teilen Sie die Arbeit in Abschnitte auf und geben Sie den Kontrollfluss zwischen diesen Abschnitten mithilfe von setTimeout(chunkHandler, 0) oder requestIdleCallback() an den Ereignisloop zurück. Dadurch hat der Browser Zeit, zwischen den Abschnitten Klicks zu verarbeiten und Frames neu darzustellen.
Zweitens sollten Sie genau wissen, was async/await im Hintergrund tatsächlich bewirkt. Es handelt sich dabei um syntaktischen „Zucker“, der auf Promises aufbaut: Jeder Code, der vor dem ersten await geschrieben wird, wird synchron ausgeführt, während alles nach einem await in einen Microtask-Callback eingebettet wird, der pausiert bleibt, bis die erwartete Promise abgeschlossen ist.
Drittens sollten Timer niemals als präzise Stoppuhren betrachtet werden. Wenn Sie etwas wie einen Timer innerhalb einer Anwendung oder einen Audio-Sequenzer entwickeln, verlassen Sie sich nicht auf setInterval oder setTimeout, um präzise Millisekundenmarken zu erreichen. Systemlast, das Zurückhalten von Hintergrundtabellen durch Browser zum Akkuschonung sowie Engpässe in den Warteschlangen führen alle zu Abweichungen. Messen Sie stattdessen die verstrichene Zeit anhand der Systemuhr mit performance.now().
JavaScript, das auf einem einzigen Thread läuft, ist keine Einschränkung, die umgangen werden muss – es handelt sich dabei um eine der stärksten Konstruktionsentscheidungen der Sprache. Durch die Kombination eines schnellen synchronen Engines mit einer Reihe von Hintergrund-APIs des Browsers sowie einem streng verwalteten Ereigniszyklus erzeugt JavaScript den Eindruck, mehrere Aufgaben gleichzeitig ausführen zu können, wobei dabei Rennbedingungen, Deadlocks sowie Probleme bezüglich der Thread-Sicherheit vermieden werden, die bei echtem Multithreading in anderen Sprachen auftreten.
Sobald Sie verstanden haben, wie Warteschlangen funktionieren, und sie respektieren, wird die Ausführungsweise Ihres Codes nicht mehr überraschend sein.