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.
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.
Verwandte Artikel
- Node.js 26: Temporal API, Map Upserts und Undici 8 erläutert — Erklärt die wichtigsten Änderungen in Node.js 26 für den Backend-Bereich, einschließlich der stabilen Temporal API, nativer Map-Upsert-Methode, Leistungsverbesserungen durch Undici 8 sowie Änderungen, die vor einem Upgrade überprüft werden müssen.
- React Rendering erklärt: State-Updates zu Bildpunkten auf dem Bildschirm — Erfahren Sie, wie Reacts Render-, Reconciliation- und Commit-Phasen mit dem Layout-, Paint- und Composite-Pipeline des Browsers verbunden sind, um Bildpunkte zu erzeugen.