Startseite / Artikel

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.

Verlag: REACTAPP.TOP16 Min. Lesezeit3605 Wörter

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.
  • Funktionsfabriken – Erstellung von angepassten Funktionen auf Basis von Parametern.
  • Callbacks – Speichern von Werten, damit diese weiterhin verfügbar sind, wenn der Callback schließlich ausgelöst wird.
  • Timer – Zugriff auf Variablen von innen aus setTimeout oder setInterval.
  • Event-Handler – Speichern von Daten, die mit einem bestimmten Ereignis verbunden sind.
  • React – Verständnis von Callbacks, Effects und dem Problem der „stale closure“.
  • 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:

    1. 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.
    2. 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.
    3. 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.
  • Der Flurbetreuer (der Event-Loop). Dieser Betreuer wechselt ständig zwischen der Theke und dem Koch hin und her und befolgt eine strenge Regel: Der Koch darf niemals unterbrochen werden, solange er arbeitet. Sobald die Hände des Kochs vollständig frei sind, holt der Betreuer das nächste Ticket von der Theke und übergibt es.
  • 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:

    • stepOne wird auf den Stack gelegt.
    • stepOne ruft stepTwo auf, wodurch stepTwo an die Spitze des Stacks gelegt wird.
    • stepTwo ruft stepThree auf, wodurch stepThree noch weiter an die Spitze des Stacks gelegt wird.
    • stepThree führt console.log aus, 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.
  • Der Stack kehrt wieder zum leeren Zustand zurück.
  • 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() und setInterval() geplant werden
    • DOM-Event-Listener wie click, scroll oder keydown
    • 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 await innerhalb einer async-Funktion ausgeführt wird
    • Alles, was mit queueMicrotask() geplant wurde
    • Callbacks, die von einem MutationObserver ausgelö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:

    1. Entleeren Sie den Aufrufstapel. Setzen Sie die Ausführung des synchronen Codes fort, bis auf dem Stapel absolut nichts mehr übrig ist.
    2. 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.
    3. 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.
    4. 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.
    5. 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.