Eine kurze Einführung

Willkommen in der wunderbaren Welt der Generatorfunktionen und asynchronen Generatoren in JavaScript.

Sie sind dabei, etwas über eine der exotischsten (laut der Mehrheit der Entwickler) JavaScript-Funktionen zu lernen.

Legen wir los!

Generatorfunktionen in JavaScript

Was ist eine Generatorfunktion?

Eine Generatorfunktion (ECMAScript 2015) in JavaScript ist eine spezielle Art von synchroner Funktion, die ihre Ausführung nach Belieben anhalten und wieder aufnehmen kann.

Im Gegensatz zu regulären JavaScript-Funktionen, die „fire and forget“ sind, haben Generatorfunktionen auch die Fähigkeit:

  • mit dem Aufrufer über einen bidirektionalen Kanal zu kommunizieren.
  • ihren Ausführungskontext (Scope) über nachfolgende Aufrufe hinweg zu behalten.

Sie können sich Generatorfunktionen wie Closures auf Steroiden vorstellen, aber die Ähnlichkeiten hören hier auf!

Ihre erste Generatorfunktion

Um eine Generatorfunktion zu erstellen, setzen wir einen Stern * nach dem Schlüsselwort function:

function* generate() {//}

Anmerkung: Generatorfunktionen können auch die Form einer Klassenmethode oder eines Funktionsausdrucks annehmen. Im Gegensatz dazu sind Pfeilgeneratorfunktionen nicht erlaubt.

Innerhalb der Funktion können wir yield verwenden, um die Ausführung anzuhalten:

function* generate() { yield 33; yield 99;}

yield pausiert die Ausführung und gibt ein sogenanntes Generator Objekt an den Aufrufer zurück. Dieses Objekt ist gleichzeitig ein Iterable und ein Iterator.

Lassen Sie uns diese Konzepte entmystifizieren.

Iterables und Iteratoren

Ein iterables Objekt in JavaScript ist ein Objekt, das Symbol.iterator implementiert. Hier ist ein minimales Beispiel für eine Iterable:

const iterable = { : function() { /* TODO: iterator */ }};

Wenn wir dieses iterable Objekt haben, weisen wir eine Funktion zu, um ein Iterator-Objekt zurückzugeben.

Das klingt nach viel Theorie, aber in der Praxis ist eine Iterable ein Objekt, über das wir mit for...of (ECMAScript 2015) eine Schleife ziehen können. Mehr dazu in einer Minute.

Sie sollten bereits einige Iterables in JavaScript kennen: Arrays und Strings zum Beispiel sind Iterables:

for (const char of "hello") { console.log(char);}

Weitere bekannte Iterables sind Map und Set. for...of ist auch nützlich für die Iteration über Objektwerte:

const person = { name: "Juliana", surname: "Crain", age: 32};for (const value of Object.values(person)) { console.log(value);}

Denken Sie daran, dass jede Eigenschaft, die als enumerable: false markiert ist, in der Iteration nicht auftaucht:

const person = { name: "Juliana", surname: "Crain", age: 32};Object.defineProperty(person, "city", { enumerable: false, value: "London"});for (const value of Object.values(person)) { console.log(value);}// Juliana// Crain// 32

Das Problem mit unserer benutzerdefinierten Iterable ist, dass sie ohne einen Iterator nicht weit kommt.

Iteratoren sind auch Objekte, aber sie sollten sich an das Iterator-Protokoll halten. Kurz gesagt, Iteratoren müssen mindestens eine next()-Methode haben.

next() muss ein anderes Objekt zurückgeben, dessen Eigenschaften value und done sind.

Die Logik für unsere next()-Methode muss den folgenden Regeln gehorchen:

  • Wir geben ein Objekt mit done: false zurück, um die Iteration fortzusetzen.
  • Wir geben ein Objekt mit done: true zurück, um die Iteration zu beenden.

value sollte stattdessen das Ergebnis enthalten, das wir für den Verbraucher erzeugen wollen.

Erweitern wir unser Beispiel um den Iterator:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Hier haben wir ein iterable, das Symbol.iterator korrekt implementiert. Wir haben auch einen Iterator, der zurückgibt:

  • ein Objekt, dessen Form { value: x, done: false} ist, bis count 3 erreicht.
  • ein Objekt, dessen Form { value: x, done: true} ist, wenn count 3 erreicht.

Dieses minimale iterierbare Objekt kann mit for...of iteriert werden:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};for (const iterableElement of iterable) { console.log(iterableElement);}

Das Ergebnis wird sein:

123

Wenn du Generatorfunktionen besser kennenlernst, wirst du sehen, dass Iteratoren die Grundlage für Generatorobjekte sind.

Denken Sie daran:

  • Iterables sind die Objekte, über die wir iterieren.
  • Iteratoren sind die Dinge, die das Iterable … „loopable“ over.

In end, what’s the point of an iterable?

We now have a standard, reliable for...of loop which works virtually for almost any data structure, custom or native, in JavaScript.

Um for...of für Ihre benutzerdefinierte Datenstruktur zu verwenden, müssen Sie:

  • eine Symbol.iteratorimplementieren.
  • ein Iteratorobjekt bereitstellen.

Das war’s!

Weitere Ressourcen:

  • Iterators gonna iterate von Jake Archibald.

Iterablen sind spreizbar und zerstörbar

Zusätzlich zu for...of können wir auch Spreizung und Destrukturierung auf endliche Iterablen anwenden.

Betrachten wir noch einmal das vorherige Beispiel:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Um alle Werte herauszuholen, können wir die Iterable in ein Array spreizen:

const values = ;console.log(values); // 

Um stattdessen nur ein paar Werte herauszuholen, können wir die Iterable mit einem Array destrukturieren. Hier erhalten wir den ersten und den zweiten Wert aus der Iterable:

const = iterable;console.log(first); // 1console.log(second); // 2

Hier erhalten wir stattdessen den ersten und den dritten Wert:

const = iterable;console.log(first); // 1console.log(third); // 3

Lassen Sie uns nun wieder unseren Fokus auf Generatorfunktionen richten.

Daten aus einer Generatorfunktion extrahieren

Wenn eine Generatorfunktion vorhanden ist, können wir mit ihr interagieren. Diese Interaktion besteht darin:

  • Schrittweise Werte aus dem Generator zu holen.
  • Optional Werte an den Generator zurückzuschicken.

Um Werte aus einem Generator zu ziehen, können wir drei Ansätze verwenden:

  • Aufruf von next() auf das Iterator-Objekt.
  • Iteration mit for...of.
  • Spreading und Array-Destrukturierung.

Eine Generatorfunktion berechnet nicht alle ihre Ergebnisse in einem einzigen Schritt, wie es normale Funktionen tun.

Wenn wir unser Beispiel nehmen, um Werte vom Generator zu erhalten, können wir zunächst den Generator aufwärmen:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();

Hier wird go unser iterable/iterator object, das Ergebnis des Aufrufs von generate.

(Denken Sie daran, dass ein Generator-Objekt sowohl ein Iterable als auch ein Iterator ist).

Von nun an können wir go.next() aufrufen, um die Ausführung voranzutreiben:

function* generate() { yield 33; yield 99;}const go = generate();// Consume the generatorconst { value: firstStep } = go.next(); // firstStep is 33const { value: secondStep } = go.next(); // secondStep is 99

Hier erzeugt jeder Aufruf von go.next() ein neues Objekt. Im Beispiel destrukturieren wir die Eigenschaft value aus diesem Objekt.

Objekte, die durch den Aufruf von next() auf dem Iterator-Objekt zurückgegeben werden, haben zwei Eigenschaften:

  • value: der Wert für den aktuellen Schritt.
  • done: ein Boolescher Wert, der angibt, ob es weitere Werte im Generator gibt oder nicht.

Wir haben ein solches Iterator-Objekt im vorherigen Abschnitt implementiert. Wenn Sie einen Generator verwenden, ist das Iterator-Objekt bereits für Sie da.

next() funktioniert gut, um endliche Daten aus einem Iterator-Objekt zu extrahieren.

Um über nicht endliche Daten zu iterieren, können wir for...of verwenden. Hier ist ein Endlosgenerator:

function* endlessGenerator() { let counter = 0; while (true) { counter++; yield counter; }}// Consume the generatorfor (const value of endlessGenerator()) { console.log(value);}

Wie Sie sehen können, muss der Generator nicht initialisiert werden, wenn Sie for...of verwenden.

Schließlich können wir das Generatorobjekt auch verteilen und destrukturieren. Hier ist die Verteilung:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // 

Hier ist die Destrukturierung:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Destructuringconst = go;console.log(first); // 1console.log(second); // 2

Es ist wichtig zu beachten, dass Generatoren erschöpft sind, sobald man alle ihre Werte verbraucht hat.

Wenn man Werte aus einem Generator ausgibt, bleibt nichts mehr übrig:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // // Exhaustconst = go;console.log(first); // undefinedconsole.log(second); // undefined

Generatoren funktionieren auch andersherum: Sie können Werte oder Befehle vom Aufrufer annehmen, wie wir gleich sehen werden.

Zurück zu Generatorfunktionen

Iterator-Objekte, das Ergebnis eines Generatorfunktionsaufrufs, stellen die folgenden Methoden zur Verfügung:

  • next()
  • return()
  • throw()

Wir haben bereits next() gesehen, das beim Herausziehen von Objekten aus einem Generator hilft.

Seine Verwendung beschränkt sich nicht nur auf das Extrahieren von Daten, sondern auch auf das Senden von Werten an den Generator.

Betrachten wir den folgenden Generator:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}

Er führt einen Parameter string auf. Wir geben dieses Argument bei der Initialisierung an:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");

Sobald wir next() zum ersten Mal auf dem Iteratorobjekt aufrufen, beginnt die Ausführung und erzeugt „A“:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // A

An dieser Stelle können wir mit dem Generator kommunizieren, indem wir ein Argument für next bereitstellen:

go.next("b");

Hier ist das komplette Listing:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // B

Von nun an können wir yield jedes Mal mit Werten füttern, wenn wir eine neue Zeichenkette in Großbuchstaben benötigen:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // Bconsole.log(go.next("c").value); // Cconsole.log(go.next("d").value); // D

Wenn wir zu irgendeinem Zeitpunkt von der Ausführung insgesamt zurückkehren wollen, können wir return auf das Iterator-Objekt anwenden:

const { value } = go.return("stop it");console.log(value); // stop it

Damit wird die Ausführung gestoppt.

Neben Werten können Sie auch eine Ausnahme in den Generator werfen. Siehe „Fehlerbehandlung für Generatorfunktionen“.

Anwendungsfälle für Generatorfunktionen

Die meisten Entwickler (mich eingeschlossen) sehen Generatorfunktionen als ein exotisches JavaScript-Feature, das in der realen Welt wenig oder gar keine Anwendung hat.

Das mag für die durchschnittliche Frontend-Arbeit zutreffen, bei der ein bisschen jQuery und ein bisschen CSS meistens ausreichen.

In Wirklichkeit glänzen Generatorfunktionen in all jenen Szenarien, in denen Leistung an erster Stelle steht.

Insbesondere sind sie gut für:

  • Arbeiten mit großen Dateien und Datensätzen.
  • Datenverarbeitung im Back-End oder im Front-End.
  • Generieren von unendlichen Datenfolgen.
  • Berechnen teurer Logik bei Bedarf.

Generatorfunktionen sind auch der Baustein für anspruchsvolle asynchrone Muster mit asynchronen Generatorfunktionen, unser Thema für den nächsten Abschnitt.

Asynchrone Generatorfunktionen in JavaScript

Was ist eine asynchrone Generatorfunktion?

Eine asynchrone Generatorfunktion (ECMAScript 2018) ist ein spezieller Typ einer asynchronen Funktion, die ihre Ausführung nach Belieben anhalten und wieder aufnehmen kann.

Der Unterschied zwischen synchronen Generatorfunktionen und asynchronen Generatorfunktionen besteht darin, dass letztere ein asynchrones, auf einem Versprechen basierendes Ergebnis aus dem Iteratorobjekt zurückgeben.

Gleich wie Generatorfunktionen sind asynchrone Generatorfunktionen in der Lage:

  • mit dem Aufrufer zu kommunizieren.
  • ihren Ausführungskontext (Umfang) über nachfolgende Aufrufe hinweg beizubehalten.

Ihre erste asynchrone Generatorfunktion

Um eine asynchrone Generatorfunktion zu erstellen, deklarieren wir eine Generatorfunktion mit dem Stern *, dem ein async vorangestellt ist:

async function* asyncGenerator() { //}

Innerhalb der Funktion können wir yield verwenden, um die Ausführung anzuhalten:

async function* asyncGenerator() { yield 33; yield 99;}

Hier pausiert yield die Ausführung und gibt ein sogenanntes Generator-Objekt an den Aufrufer zurück.

Dieses Objekt ist gleichzeitig eine Iterable und ein Iterator.

Lassen Sie uns diese Konzepte rekapitulieren, um zu sehen, wie sie in das asynchrone Land passen.

Asynchrone Iterables und Iteratoren

Eine asynchrone Iterable in JavaScript ist ein Objekt, das Symbol.asyncIterator implementiert.

Hier ist ein minimales Beispiel:

const asyncIterable = { : function() { /* TODO: iterator */ }};

Wenn wir dieses iterable Objekt haben, weisen wir eine Funktion zu, um ein Iterator-Objekt zurückzugeben.

Das Iterator-Objekt sollte dem Iterator-Protokoll mit einer next()-Methode entsprechen (wie der synchrone Iterator).

Erweitern wir unser Beispiel, indem wir den Iterator hinzufügen:

const asyncIterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return Promise.resolve({ value: count, done: false }); } return Promise.resolve({ value: count, done: true }); } }; }};

Dieser Iterator ähnelt dem, den wir in den vorherigen Abschnitten gebaut haben, diesmal ist der einzige Unterschied, dass wir das zurückgebende Objekt mit Promise.resolve umhüllen.

An dieser Stelle können wir etwas in dieser Richtung machen:

const go = asyncIterable();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));// 1// 2

Oder mit for await...of:

async function consumer() { for await (const asyncIterableElement of asyncIterable) { console.log(asyncIterableElement); }}consumer();// 1// 2// 3

Asynchrone Iterables und Iteratoren sind die Grundlage für asynchrone Generatorfunktionen.

Wenden wir uns nun wieder ihnen zu.

Extrahieren von Daten aus einem asynchronen Generator

Asynchrone Generatorfunktionen berechnen nicht alle ihre Ergebnisse in einem einzigen Schritt, wie es reguläre Funktionen tun.

Stattdessen ziehen wir die Werte Schritt für Schritt heraus.

Nachdem wir asynchrone Iteratoren und Iterables untersucht haben, sollte es nicht überraschen, dass wir zwei Ansätze verwenden können, um Promises aus einem asynchronen Generator zu ziehen:

  • Aufruf von next() auf dem Iteratorobjekt.
  • async Iteration mit for await...of.

In unserem ersten Beispiel können wir:

async function* asyncGenerator() { yield 33; yield 99;}const go = asyncGenerator();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));

Die Ausgabe dieses Codes ist:

3399

Der andere Ansatz verwendet async Iteration mit for await...of. Um die asynchrone Iteration zu verwenden, umhüllen wir den Verbraucher mit einer async-Funktion.

Hier ist das vollständige Beispiel:

async function* asyncGenerator() { yield 33; yield 99;}async function consumer() { for await (const value of asyncGenerator()) { console.log(value); }}consumer();

for await...of funktioniert gut, um nicht endliche Datenströme zu extrahieren.

Lassen Sie uns nun sehen, wie man Daten zurück an den Generator sendet.

Zurück zu asynchronen Generatorfunktionen

Betrachten wir den folgenden asynchronen Generator:

async function* asyncGenerator(string) { while (true) { string = yield string.toUpperCase(); }}

Gleich der endlosen Großbuchstabenmaschine aus dem Generatorbeispiel können wir ein Argument an next() übergeben:

async function* asyncEndlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}async function consumer() { const go = await asyncEndlessUppercase("a"); const { value: firstStep } = await go.next(); console.log(firstStep); const { value: secondStep } = await go.next("b"); console.log(secondStep); const { value: thirdStep } = await go.next("c"); console.log(thirdStep);}consumer();

Hier sendet jeder Schritt einen neuen Wert in den Generator.

Die Ausgabe dieses Codes ist:

ABC

Auch wenn es nichts inhärent Asynchrones in toUpperCase() gibt, kann man den Zweck dieses Musters erkennen.

Wenn wir zu irgendeinem Zeitpunkt aus der Ausführung aussteigen wollen, können wir return() auf dem Iterator-Objekt aufrufen:

 const { value } = await go.return("stop it"); console.log(value); // stop it

Neben Werten können Sie auch eine Ausnahme in den Generator werfen. Siehe „Fehlerbehandlung für asynchrone Generatoren“.

Anwendungsfälle für asynchrone Iterables und asynchrone Generatorfunktionen

Wenn Generatorfunktionen gut für die synchrone Arbeit mit großen Dateien und unendlichen Sequenzen sind, ermöglichen asynchrone Generatorfunktionen ein ganz neues Land der Möglichkeiten für JavaScript.

Insbesondere die asynchrone Iteration macht es einfacher, lesbare Streams zu konsumieren. Das Response-Objekt in Fetch stellt body als lesbaren Stream mit getReader() dar. Wir können einen solchen Stream mit einem asynchronen Generator umhüllen und später mit for await...of darüber iterieren.

Async iterators and generators von Jake Archibald hat eine Reihe von schönen Beispielen.

Andere Beispiele für Streams sind Request Streams mit Fetch.

Zum Zeitpunkt des Schreibens gibt es keine Browser-API, die Symbol.asyncIterator implementiert, aber die Stream-Spezifikation wird dies ändern.

In Node.

In Zukunft werden wir in der Lage sein, beschreibbare, lesbare und transformierende Streams auf der Client-Seite zu konsumieren und nahtlos mit ihnen zu arbeiten.

Weitere Ressourcen:

  • Die Stream API.

Zusammenfassung

Schlüsselbegriffe und Konzepte, die wir in diesem Beitrag behandelt haben:

ECMAScript 2015:

  • iterable.
  • Iterator.

Dies sind die Bausteine für Generatorfunktionen.

ECMAScript 2018:

  • Asynchrone Iterable.
  • Asynchroner Iterator.

Diese sind stattdessen die Bausteine für asynchrone Generatorfunktionen.

Ein gutes Verständnis von Iterables und Iteratoren kann Sie sehr weit bringen. Es ist nicht so, dass Sie jeden Tag mit Generatorfunktionen und asynchronen Generatorfunktionen arbeiten werden, aber es ist eine gute Fähigkeit, die Sie in Ihrem Werkzeuggürtel haben sollten.

Und Sie? Haben Sie sie schon einmal benutzt?

Danke für die Lektüre und bleiben Sie dran an diesem Blog!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.