Startseite / Artikel / Erklärung zu JavaScript-Generatoren: Unterbrechbare Funktionen und verspätete Iteration

Erklärung zu JavaScript-Generatoren: Unterbrechbare Funktionen und verspätete Iteration

Erfahren Sie, wie JavaScript-Generatorfunktionen die Ausführung pausieren und wieder aufnehmen, das Iterator-Protokoll umsetzen sowie ein verzögertes, memoryeffizientes Datenstreaming ermöglichen.

1084 Wörter

Jede Funktion, die Sie bisher geschrieben haben, befolgt eine Regel: Sie rufen sie auf, sie wird von Anfang bis Ende ausgeführt, gibt einmal einen Wert zurück – und das ist’s. Generatoren brechen diese Regel stillschweigend, und dieses Brechen erweist sich in der Praxis als sehr wichtig: Eine Generatorfunktion kann ihren Ablauf mitten im Lauf unterbrechen, einen Wert an den Aufrufer zurückgeben und später genau an dieser Stelle fortsetzen, als wäre keine Zeit vergangen.

Der grundlegende Mechanismus

Man deklariert einen Generator mit function*, und anstelle von return verwendet man yield, um die Werte nacheinander auszugeben:

function* countUp() {
  console.log("starting");
  yield 1;
  console.log("resumed after first yield");
  yield 2;
  console.log("resumed after second yield");
  yield 3;
  console.log("done");
}
const counter = countUp();
console.log(counter.next()); // "starting" logs, then { value: 1, done: false }
console.log(counter.next()); // "resumed after first yield" logs, then { value: 2, done: false }
console.log(counter.next()); // "resumed after second yield" logs, then { value: 3, done: false }
console.log(counter.next()); // "done" logs, then { value: undefined, done: true }

Durch Aufruf von countUp() wird kein Teil des Funktionskörpers ausgeführt. Es wird ein Generator-Objekt zurückgegeben, eine inaktive Struktur, die noch nicht mit der Ausführung begonnen hat. Jeder nachfolgende Aufruf von .next() setzt die Ausführung genau dort fort, wo sie zuvor unterbrochen wurde, läuft weiter, bis auf den nächsten yield gestoßen wird, und friert erneut ein, wobei der gerade ausgegebene Wert zurückgegeben wird. Der gesamte interne Zustand der Funktion, ihre Variablen, ihre Position innerhalb einer Schleife – einfach alles – bleibt während dieser Pausen unverändert erhalten, als wäre die Funktion nie tatsächlich unterbrochen worden.

Warum dies sich wirklich von einem einfachen Array-Rückgabewert unterscheidet

Ein natürlicher Einwand lautet: Warum nicht einfach ein Array erstellen und dieses stattdessen zurückgeben? Genau hier kommen Generatoren ins Spiel, da sie die Ausgabe verzögert, Element für Element und nur auf Anfrage erzeugen, anstatt das gesamte Ergebnisset im Voraus zu berechnen.

function* readLargeFileLineByLine(filePath) {
  const fileHandle = fs.openSync(filePath, "r");
  let position = 0;
  let leftover = "";
  while (true) {
    const buffer = Buffer.alloc(1024);
    const bytesRead = fs.readSync(fileHandle, buffer, 0, 1024, position);
    if (bytesRead === 0) break;    position += bytesRead;
    const lines = (leftover + buffer.toString("utf8", 0, bytesRead)).split("\n");
    leftover = lines.pop();
    for (const line of lines) yield line;
  }
  fs.closeSync(fileHandle);
}for (const line of readLargeFileLineByLine("access.log")) {
  if (line.includes("500")) console.log(line);
  // only reads and holds one small chunk of the file in memory at a time
}

Hätte man dies so geschrieben, dass zuerst ein Array mit allen Zeilen erstellt wird, müsste eine mehrere Gigabyte große Protokolldatei vollständig in den Speicher geladen werden, bevor man auch nur eine einzige Zeile darauf abrufen kann – das ist genau das Problem, das strömende Ansätze vermeiden sollen. Generatoren umgehen dieses Problem durch einen anderen Mechanismus: Jede Zeile wird erst dann berechnet, wenn etwas sie über .next() anfordert. Wenn beispielsweise die zugehörige for...of-Schleife frühzeitig beendet wird, sobald die gesuchte Zeile gefunden wurde, kümmert sich der Generator nicht darum, etwas jenseits dieses Punktes zu berechnen.

Generatoren implementieren das Iterator-Protokoll, weshalb for...of einfach funktioniert

Strukturen wie for...of, Array-Destructuring und der Spread-Operator arbeiten alle mit allem, was das Iterator-Protokoll erfüllt – also mit jedem Objekt, das eine next()-Methode bereitstellt, die { value, done } zurückgibt. Ein Generator-Objekt passt automatisch in dieses Muster, ohne dass Sie etwas zusätzlich tun müssen – das erklärt, warum man direkt über einen Generator iterieren kann, ohne weitere Anpassungen.

function* pageThroughResults(fetchPage) {
  let page = 1;
  while (true) {
    const results = fetchPageSync(fetchPage, page);
    if (results.length === 0) return;
    yield* results; // delegates to another iterable, yielding each of its values
    page++;
  }
}

Die yield*-Syntax delegiert an ein anderes Iterierbares und leitet dessen Werte nacheinander weiter. So kann ein Generator eine Reihe von paginierten Ergebnissen in einen durchgängigen Strom einzelner Elemente umwandeln, ohne dass der verarbeitende Code jemals wissen muss, dass eine Paginierung im Hintergrund stattfindet.

Zweiwegekommunikation: .next() kann Werte senden, nicht nur abrufen

Es gibt eine weniger bekannte Seite der Generatoren: yield ist nicht nur ein Weg, Werte auszusenden, sondern auch eine Expression – alles, was in den folgenden Aufruf von .next() übergeben wird, wird zum Ergebnis dieser Expression. Das bedeutet, dass Daten wieder in eine pausierte Funktion zurückgeführt werden können, nicht nur aus ihr heraus.

function* priceNegotiation() {
  const offer1 = yield "What's your offer?";
  const offer2 = yield `I can't do ${offer1}, how about a counter?`;
  return `Final: ${offer2}`;
}
const negotiation = priceNegotiation();
console.log(negotiation.next().value);        // "What's your offer?"
console.log(negotiation.next(50).value);       // "I can't do 50, how about a counter?"
console.log(negotiation.next(80).value);       // "Final: 80"

Jedes Mal, wenn Sie .next(value) aufrufen, wird der Generator wieder aktiviert und die pausierte yield-Zeile wird mit dem von Ihnen bereitgestellten value ausgewertet. Die Fähigkeit, eine Funktion so zu gestalten, dass sie pausieren kann, auf neue Eingaben vom Aufrufer wartet und anschließend mit diesen Eingaben weiterarbeiten kann, ist in der herkömmlichen Funktionsentwicklung ungewöhnlich. Genau auf dieses Mechanismus stützten sich frühe JavaScript-Async-Bibliotheken, um async/await vorzutäuschen, bevor die Sprache dies nativ unterstützte: Ein Scheduler löste eine Promise auf und führte das Ergebnis anschließend über .next() in den Generator ein, wobei dieser Vorgang bei jedem yield wiederholt wurde, bis alle asynchronen Schritte abgeschlossen waren.

Warum dies die eigentliche Grundlage ist, die man kennen sollte

Generatoren sind keineswegs eine syntaktische Besonderheit, die man gefahrlos ignorieren kann. Sie sind das Mechanismus, auf dem async/await letztendlich aufgebaut wurde, denn „Ausführung hier aussetzen und später mit einem Wert fortsetzen“ ist genau das Verhalten, das das Warten auf eine Promise erfordert. Sie erklären außerdem, warum for...of, die Spread-Syntax und das Destructuring sich konsistent bei Arrays, Strings, Map-Instanzen und benutzerdefinierten Objekten verhalten, da das Iterator-Protokoll, das Generatoren implementieren, identisch mit dem ist, auf dem diese eingebauten Typen bereits beruhen. Sobald man das eigentliche mentale Modell verinnerlicht hat, erscheinen Funktionen, die in der Lage sind, anzuhalten und fortzusetzen sowie bei jedem Pausepunkt Werte auszutauschen, die träge Auswertung, benutzerdefinierte Iterationslogik sowie die internen Abläufe von async/await nicht mehr wie drei unzusammenhängende Funktionen. Tatsächlich handelt es sich dabei um ein einheitliches Konzept.

Das zugrunde liegende Muster, das in drei verschiedenen Kontexten auftaucht.

Verwandte Artikel