Kapitel 4. Generatoren

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In Kapitel 2 haben wir zwei wesentliche Nachteile der asynchronen Ablaufsteuerung mit Callbacks herausgearbeitet:

  • Rückrufbasierte Asynchronität passt nicht dazu, wie unser Gehirn die Schritte einer Aufgabe plant.

  • Rückrufe sind wegen der Umkehrung der Kontrolle nicht vertrauenswürdig oder zusammensetzbar.

In Kapitel 3 haben wir beschrieben, wie Promises die Umkehrung der Kontrolle über Rückrufe rückgängig machen und die Vertrauenswürdigkeit/Kompatibilität wiederherstellen.

Jetzt wenden wir uns der asynchronen Ablaufsteuerung in einer sequenziellen, synchronen Weise zu. Die "Magie", die dies möglich macht, sind die ES6-Generatoren.

Breaking Run-to-Completion

In Kapitel 1 haben wir eine Erwartung erläutert, auf die sich JS-Entwickler in ihrem Code fast durchgängig verlassen: Sobald eine Funktion ausgeführt wird, läuft sie, bis sie abgeschlossen ist, und kein anderer Code kann sie unterbrechen und dazwischen laufen.

So bizarr es auch klingen mag, ES6 führt eine neue Art von Funktion ein, die sich nicht mit dem Run-to-Completion-Verhalten verhält. Diese neue Art von Funktion wird Generator genannt.

Um die Auswirkungen zu verstehen, lass uns dieses Beispiel betrachten:

var x = 1;

function foo() {
    x++;
    bar();              // <-- what about this line?
    console.log( "x:", x );
}

function bar() {
    x++;
}

foo();                  // x: 3

In diesem Beispiel wissen wir mit Sicherheit, dass bar() zwischen x++ undconsole.log(x) liegt. Aber was wäre, wenn bar() nicht da wäre? Das Ergebnis wäre dann natürlich 2 statt 3.

Jetzt lass uns dein Gehirn verdrehen. Was wäre, wenn bar() nicht vorhanden wäre, aber trotzdem irgendwie zwischen den Anweisungen x++ und console.log(x) laufen könnte? Wie wäre das möglich?

In preemptiven Multithreading-Sprachen wäre es möglich, dass bar() genau im richtigen Moment zwischen diesen beiden Anweisungen unterbrochen und ausgeführt wird. Aber JS ist weder preemptiv noch (derzeit) multithreaded. Dennoch ist eine kooperative Form dieser Unterbrechung (Gleichzeitigkeit) möglich, wenn foo() selbst irgendwie eine Pause an dieser Stelle des Codes anzeigen könnte.

Hinweis

Ich verwende das Wort "kooperativ" nicht nur wegen der Verbindung zur klassischen Gleichzeitigkeitsterminologie (siehe Kapitel 1), sondern auch, weil die ES6-Syntax für die Angabe eines Pausenpunkts im Code yieldlautet, was auf eine höfliche kooperative Übergabe der Kontrolle hindeutet (siehe nächstes Snippet).

Hier ist der ES6-Code, um eine solche kooperative Gleichzeitigkeit zu erreichen:

var x = 1;

function *foo() {
    x++;
    yield; // pause!
    console.log( "x:", x );
}

function bar() {
    x++;
}
Hinweis

Du wirst wahrscheinlich in den meisten anderen JS-Dokumentationen/Codes sehen, dass eine Generator-Deklaration als function* foo() { .. } formatiert wird, anstatt wie hier mit function *foo() { .. }- der einzige Unterschied ist die stilistische Positionierung des *. Die beiden Formen sind funktionell/syntaktisch identisch, ebenso wie eine drittefunction*foo() { .. } (ohne Leerzeichen) Form. Es gibt Argumente für beide Formen, aber grundsätzlich bevorzuge ich function *foo.., weil es dann passt, wenn ich beim Schreiben auf einen Generator mit *foo() verweise. Wenn ich nurfoo() sagen würde, wüsste man nicht so genau, ob ich von einem Generator oder einer regulären Funktion spreche. Das ist eine rein stilistische Vorliebe.

Wie können wir nun den Code in dem vorherigen Schnipsel so ausführen, dass bar()an der Stelle von yield innerhalb von *foo() ausgeführt wird?

// construct an iterator `it` to control the generator
var it = foo();

// start `foo()` here!
it.next();
x;                      // 2
bar();
x;                      // 3
it.next();              // x: 3

Okay, diese beiden Codeschnipsel enthalten eine Menge neuer und potenziell verwirrender Dinge, wir müssen uns also durch einiges durcharbeiten. Aber bevor wir die verschiedenen Mechanismen/Syntaxen der ES6-Generatoren erklären, lass uns erst einmal den Verhaltensablauf durchgehen:

  1. Die Operation it = foo() führt den *foo() Generator noch nicht aus, sondern konstruiert lediglich einen Iterator, der seine Ausführung steuert. Mehr zu Iteratoren in Kürze.

  2. Die erste it.next() startet den Generator *foo() und führt diex++ auf der ersten Zeile von *foo() aus.

  3. *foo() wird bei der Anweisung yield angehalten, wodurch der erste Aufruf vonit.next() beendet wird. Im Moment läuft *foo() noch und ist aktiv, aber es befindet sich in einem Pausenzustand.

  4. Wir überprüfen den Wert von x, und der ist jetzt 2.

  5. Wir rufen bar() auf, das x wieder mit x++ inkrementiert.

  6. Wir sehen uns den Wert von x noch einmal an, und er lautet jetzt 3.

  7. Der letzte Aufruf it.next() setzt den Generator *foo() an der Stelle fort, an der er angehalten wurde, und führt die Anweisung console.log(..) aus, die den aktuellen Wert von x von 3 verwendet.

Es ist klar, dass foo() gestartet wurde, aber nicht bis zum Ende lief, sondern auf yield pausierte. Wir nahmen foo() später wieder auf und ließen es zu Ende laufen, aber das war gar nicht nötig.

Ein Generator ist also eine besondere Art von Funktion, die ein oder mehrere Male starten und stoppen kann und nicht unbedingt jemals enden muss. Es ist zwar noch nicht ganz klar, warum das so mächtig ist, aber im weiteren Verlauf dieses Kapitels wird das einer der grundlegenden Bausteine sein, die wir verwenden, um Generatoren als asynchrone Ablaufsteuerung als Muster für unseren Code zu konstruieren.

Eingabe und Ausgabe

Eine Generatorfunktion ist eine spezielle Funktion mit dem neuen Verarbeitungsmodell, auf das wir gerade angespielt haben. Aber es ist immer noch eine Funktion, was bedeutet, dass sich einige Grundprinzipien nicht geändert haben - nämlich, dass sie immer noch Argumente akzeptiert (auch Eingabe genannt) und dass sie immer noch einen Wert zurückgeben kann (auch Ausgabe genannt):

function *foo(x,y) {
    return x * y;
}

var it = foo( 6, 7 );

var res = it.next();

res.value;      // 42

Wir übergeben die Argumente 6 und 7 als Parameter xbzw. y an *foo(..). Und *foo(..) gibt den Wert 42 an den aufrufenden Code zurück.

Jetzt sehen wir einen Unterschied beim Aufruf des Generators im Vergleich zu einer normalen Funktion. foo(6,7) sieht offensichtlich vertraut aus. Aber der*foo(..) Generator wurde noch nicht wirklich ausgeführt, wie es bei einer Funktion der Fall wäre.

Stattdessen erstellen wir einfach ein Iterator-Objekt, das wir der Variablen it zuweisen, um den *foo(..) Generator zu steuern. Dann rufen wirit.next() auf, das den *foo(..) Generator anweist, von seiner aktuellen Position aus weiterzugehen und entweder am nächsten yield oder am Ende des Generators anzuhalten.

Das Ergebnis dieses next(..) Aufrufs ist ein Objekt mit einer value Eigenschaft, die den Wert (wenn überhaupt) enthält, der von *foo(..) zurückgegeben wurde. Mit anderen Worten: yield hat während der Ausführung des Generators einen Wert gesendet, sozusagen als Zwischenschrittreturn.

Auch hier wird noch nicht klar sein, warum wir dieses ganze indirekteIterator-Objekt brauchen, um den Generator zu steuern. Aber das kommt schon noch,versprochen.

Iterationsnachrichten

Generatoren akzeptieren nicht nur Argumente und haben Rückgabewerte, sondern sie verfügen auch über noch leistungsfähigere und überzeugendere Ein- und Ausgabemöglichkeiten, die über yield und next(..) zur Verfügung stehen.

Bedenke:

function *foo(x) {
    var y = x * (yield);
    return y;
}

var it = foo( 6 );

// start `foo(..)`
it.next();

var res = it.next( 7 );

res.value;      // 42

Zuerst übergeben wir 6 als Parameter x. Dann rufen wir it.next() auf, und es startet *foo(..).

Innerhalb von *foo(..) beginnt die var y = x .. Anweisung mit der Verarbeitung, aber dann stößt sie auf einen yield Ausdruck. An diesem Punkt hält*foo(..) an (mitten in der Zuweisungsanweisung!) und fordert den aufrufenden Code auf, einen Ergebniswert für den Ausdruck yieldzu liefern. Als Nächstes rufen wir it.next( 7 ) auf, das den Wert 7 als Ergebnis des angehaltenen Ausdrucks yield zurückgibt.

An diesem Punkt ist die Zuweisungsanweisung also im Wesentlichenvar y = 6 * 7. Jetzt gibt return y den 42 Wert als Ergebnis des it.next( 7 ) Aufrufs zurück.

Beachte etwas sehr Wichtiges, das aber auch leicht verwirrend sein kann, selbst für erfahrene JS-Entwickler: Je nach Perspektive gibt es eine Diskrepanz zwischen dem yield und dem next(..) Aufruf. In der Regel hast du einen next(..) Aufruf mehr als yield Anweisungen - der vorangegangene Schnipsel hat einen yield und zwei next(..) Aufrufe.

Warum die Unstimmigkeit?

Denn der erste next(..) startet immer einen Generator und läuft zum ersten yield. Aber es ist der zweite next(..) Aufruf, der den ersten angehaltenen yield Ausdruck erfüllt, und der dritte next(..) würde den zweiten yield erfüllen, und so weiter.

Die Geschichte von zwei Fragen

Es hängt davon ab, an welchen Code du in erster Linie denkst, ob du eine Diskrepanz wahrnimmst oder nicht.

Betrachte nur den Generatorcode:

var y = x * (yield);
return y;

Diese erste yield stellt im Grunde eine Frage: "Welchen Wert soll ich hier eingeben?"

Wer wird diese Frage beantworten? Nun, der erste next() ist bereits gelaufen, um den Generator bis zu diesem Punkt zu bringen, also kann erdie Frage natürlich nicht beantworten. Also muss der zweite Aufruf von next(..) die Frage beantworten, die der erste Aufruf von yield gestellt hat.

Siehst du die Fehlanpassung von Sekunde zu Sekunde?

Aber lass uns die Perspektive wechseln. Betrachten wir es nicht aus der Sicht des Generators, sondern aus der Sicht des Iterators.

Um diese Perspektive richtig zu veranschaulichen, müssen wir auch erklären, dass Nachrichten in beide Richtungen gehen können -yield .. als Ausdruck kann Nachrichten als Antwort auf next(..) Aufrufe senden, und next(..) kann Werte an einen pausierten yield Ausdruck senden. Betrachte diesen leicht geänderten Code:

function *foo(x) {
    var y = x * (yield "Hello");    // <-- yield a value!
    return y;
}

var it = foo( 6 );

var res = it.next();    // first `next()`, don't pass anything
res.value;              // "Hello"

res = it.next( 7 );     // pass `7` to waiting `yield`
res.value;              // 42

yield .. und next(..) bilden während der Ausführung des Generators ein wechselseitiges Nachrichtenübermittlungssystem.

Wenn du also nur den Iterator-Code betrachtest:

var res = it.next();    // first `next()`, don't pass anything
res.value;              // "Hello"

res = it.next( 7 );     // pass `7` to waiting `yield`
res.value;              // 42
Hinweis

Wir übergeben dem ersten Aufruf von next() keinen Wert, und das ist Absicht. Nur ein pausiertes yield könnte einen solchen Wert akzeptieren, der von einemnext(..) übergeben wird, und zu Beginn des Generators, wenn wir den erstennext() aufrufen, gibt es kein pausiertes yield, das einen solchen Wert akzeptieren könnte. Die Spezifikation und alle kompatiblen Browser verwerfen einfach alles, was dem erstennext() übergeben wird. Es ist trotzdem keine gute Idee, einen Wert zu übergeben, da du damit nur verwirrenden, fehlschlagenden Code erzeugst. Beginne daher einen Generator immer mit einem argumentfreien next().

Der erste Aufruf von next() (ohne Übergabe) stellt im Grunde eine Frage: "Welchen nächsten Wert muss mir der *foo(..) Generator geben?" Und wer beantwortet diese Frage? Der erste yield "hello"Ausdruck.

Siehst du? Da gibt es keine Unstimmigkeiten.

Je nachdem, an wen du denkst, wenn du die Frage stellst, gibt es entweder eine Unstimmigkeit zwischen den Aufrufen von yield und next(..) oder nicht.

Aber halt! Es gibt immer noch eine zusätzliche next() im Vergleich zur Anzahl deryield Anweisungen. Der letzte Aufruf von it.next(7) stellt also wieder die Frage, welchen Wert der Generator als Nächstes erzeugen wird. Aber es gibt keine weiteren yield Anweisungen mehr, die man beantworten könnte, oder? Wer antwortet also?

Die Aussage von return beantwortet die Frage!

Und wenn es in deinem Generator kein return gibt -return ist in Generatoren sicherlich nicht notwendiger als in normalen Funktionen - gibt es immer ein angenommenes/implizites return; (auch bekannt als return undefined;), das dazu dient, die durch den letzten it.next(7) -Aufruf gestellte Frage standardmäßig zu beantworten.

Diese Fragen und Antworten - die bidirektionale Nachrichtenübermittlung mit yieldund next(..)- sind ziemlich mächtig, aber es ist überhaupt nicht klar, wie diese Mechanismen mit der asynchronen Flusskontrolle zusammenhängen. Wir kommen der Sache schon näher!

Mehrere Iteratoren

Wenn du einen Iteratorzur Steuerung eines Generators verwendest, scheint es so, als würdest du die deklarierte Generatorfunktion selbst steuern. Aber es gibt eine Feinheit, die leicht zu übersehen ist: Jedes Mal, wenn du einen Iterator konstruierst, konstruierst du implizit auch eine Instanz des Generators, den dieser Iterator steuern wird.

Du kannst mehrere Instanzen desselben Generators gleichzeitig laufen lassen, und sie können sogar miteinander interagieren:

function *foo() {
    var x = yield 2;
    z++;
    var y = yield (x * z);
    console.log( x, y, z );
}

var z = 1;

var it1 = foo();
var it2 = foo();

var val1 = it1.next().value;            // 2 <-- yield 2
var val2 = it2.next().value;            // 2 <-- yield 2

val1 = it1.next( val2 * 10 ).value;     // 40  <-- x:20,  z:2
val2 = it2.next( val1 * 5 ).value;      // 600 <-- x:200, z:3

it1.next( val2 / 2 );                   // y:300
                                        // 20 300 3
it2.next( val1 / 4 );                   // y:10
                                        // 200 10 3
Warnung

Der häufigste Fall, in dem mehrere Instanzen desselben Generators gleichzeitig laufen, sind nicht solche Interaktionen, sondern wenn der Generator seine eigenen Werte ohne Input produziert, vielleicht von einer unabhängig verbundenen Ressource. Wir werden im nächsten Abschnitt mehr über die Werteproduktion sprechen.

Lass uns kurz durch die Verarbeitung gehen:

  1. Beide Instanzen von *foo() werden zur gleichen Zeit gestartet, und beide Aufrufe vonnext() offenbaren ein value von 2 aus den yield 2 Anweisungen.

  2. val2 * 10 ist 2 * 10, das in die erste Generatorinstanz it1 gesendet wird, so dass x den Wert 20 erhält. z wird von 1auf 2 inkrementiert, und dann wird 20 * 2 yield herausgegeben, wodurch val1 auf 40 gesetzt wird.

  3. val1 * 5 ist 40 * 5, das in die zweite Generatorinstanz it2 gesendet wird, so dass x den Wert 200 erhält. z wird wieder inkrementiert, von 2 auf 3, und dann wird 200 * 3 yield ausgegeben, wodurch val2 auf600 gesetzt wird.

  4. val2 / 2 ist 600 / 2, der in die erste Generatorinstanz it1 gesendet wird, so dass y den Wert 300 erhält, um dann20 300 3 für seine x y z Werte auszudrucken.

  5. val1 / 4 ist 40 / 4, der in die zweite Generatorinstanz it2 gesendet wird, so dass y den Wert 10 erhält, um dann200 10 3 für seine x y z Werte auszudrucken.

Das ist ein lustiges Beispiel, das du in deinem Kopf durchgehen kannst. Hast du es richtig verstanden?

Interleaving

Erinnere dich an dieses Szenario aus dem Abschnitt "Run-to-Completion" in Kapitel 1:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

Bei normalen JS-Funktionen kann natürlich entweder foo() komplett zuerst laufen oder bar() komplett zuerst, aber foo() kann seine einzelnen Anweisungen nicht mit bar() verschachteln. Es gibt also nur zwei mögliche Ergebnisse für das vorangegangene Programm.

Mit Generatoren ist jedoch eindeutig eine Verschachtelung (sogar mitten in den Anweisungen!) möglich:

var a = 1;
var b = 2;

function *foo() {
    a++;
    yield;
    b = b * a;
    a = (yield b) + 3;
}

function *bar() {
    b--;
    yield;
    a = (yield 8) + b;
    b = a * (yield 2);
}

Je nachdem, in welcher Reihenfolge die Iteratoren, die *foo()und *bar() steuern, aufgerufen werden, könnte das obige Programm verschiedene Ergebnisse liefern. Mit anderen Worten: Wir können die in Kapitel 1 besprochenen theoretischen Wettlaufbedingungen (auf eine Art Fake-Weise) tatsächlich veranschaulichen, indem wir die beiden Generator-Interaktionen über dieselben gemeinsamen Variablen verschachteln.

Als Erstes erstellen wir eine Hilfsfunktion namens step(..), die einenIterator steuert:

function step(gen) {
    var it = gen();
    var last;

    return function() {
        // whatever is `yield`ed out, just
        // send it right back in the next time!
        last = it.next( last ).value;
    };
}

step(..) initialisiert einen Generator, um seinen it Iterator zu erstellen, und gibt dann eine Funktion zurück, die, wenn sie aufgerufen wird, den Iterator um einen Schritt weiterschaltet. Außerdem wird der zuvor yieldausgegebene Wert beim nächsten Schritt direkt wieder zurückgeschickt. So wird aus yield 8 einfach 8 und ausyield b einfach b (was auch immer es zum Zeitpunkt von yield war).

Jetzt wollen wir einfach mal ausprobieren, wie es sich auswirkt, wenn wir diese verschiedenen Teile von *foo() und *bar() miteinander verschachteln. Wir beginnen mit dem langweiligen Basisfall und stellen sicher, dass *foo() vollständig vor *bar()endet (genau wie in Kapitel 1):

// make sure to reset `a` and `b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

// run `*foo()` completely first
s1();
s1();
s1();

// now run `*bar()`
s2();
s2();
s2();
s2();

console.log( a, b );    // 11 22

Das Endergebnis ist 11 und 22, genau wie in der Version aus Kapitel 1. Jetzt wollen wir die Reihenfolge der Verschachtelung ändern und sehen, wie sich die Endwerte von a und b ändern:

// make sure to reset `a` and `b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

s2();       // b--;
s2();       // yield 8
s1();       // a++;
s2();       // a = 8 + b;
            // yield 2
s1();       // b = b * a;
            // yield b
s1();       // a = b + 3;
s2();       // b = a * 2;

Bevor ich dir die Ergebnisse verrate, kannst du herausfinden, was a und b nach dem vorangegangenen Programm sind? Nicht schummeln!

console.log( a, b );    // 12 18
Hinweis

Als Übung für den Leser kannst du versuchen, wie viele andere Kombinationen von Ergebnissen du zurückbekommst, wenn du die Reihenfolge der Aufrufe vons1() und s2() umstellst. Vergiss nicht, dass du immer drei Aufrufe von s1()und vier Aufrufe von s2() brauchst. Erinnere dich an die Diskussion über den Abgleich von next() mit yield, um die Gründe dafür zu erfahren.

Du wirst mit Sicherheit nicht absichtlich ein derartiges Durcheinander erzeugen wollen, denn das macht den Code unglaublich schwer verständlich. Aber die Übung ist interessant und lehrreich, um mehr darüber zu erfahren, wie mehrere Generatoren gleichzeitig im selben gemeinsamen Bereich laufen können, denn es wird Stellen geben, an denen diese Fähigkeit sehr nützlich ist.

Wir werden die Gleichzeitigkeit von Generatoren im Abschnitt "Gleichzeitigkeit von Generatoren" ausführlicher besprechen .

Generatorische Werte

Im vorigen Abschnitt haben wir einen interessanten Anwendungsfall für Generatoren erwähnt, nämlich die Erzeugung von Werten. Das ist zwar nicht das Hauptthema dieses Kapitels, aber wir wären nachlässig, wenn wir nicht auf die Grundlagen eingehen würden, vor allem, weil dieser Anwendungsfall im Grunde der Ursprung des Namens ist: Generatoren.

Wir werden jetzt einen kleinen Abstecher zum Thema Iteratoren machen, aber wir werden darauf zurückkommen, wie sie mit Generatoren und der Verwendung eines Generators zur Erzeugung von Werten zusammenhängen.

Erzeuger und Iteratoren

Stell dir vor, du produzierst eine Reihe von Werten, wobei jeder Wert eine definierbare Beziehung zum vorherigen Wert hat. Dazu brauchst du einen zustandsbehafteten Produzenten, der sich an den letzten Wert erinnert, den er ausgegeben hat.

Du kannst so etwas ganz einfach mit einer Funktionsschließung implementieren (siehe den Titel Scope & Closures in dieser Serie):

var gimmeSomething = (function(){
    var nextVal;

    return function(){
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        return nextVal;
    };
})();

gimmeSomething();       // 1
gimmeSomething();       // 9
gimmeSomething();       // 33
gimmeSomething();       // 105
Hinweis

Die Berechnungslogik von nextVal hätte vereinfacht werden können, aber konzeptionell wollen wir den nächsten Wert (akanextVal) erst nach dem nächsten Aufruf von gimmeSomething() berechnen, weil das im Allgemeinen ein ressourcenschwaches Design für Produzenten von dauerhafteren oder ressourcenbegrenzten Werten als einfachen numbers sein könnte.

Eine beliebige Zahlenreihe zu generieren ist kein besonders realistisches Beispiel. Aber was wäre, wenn du Datensätze aus einer Datenquelle generieren würdest? Du könntest dir den gleichen Code vorstellen.

Tatsächlich ist diese Aufgabe ein sehr verbreitetes Entwurfsmuster, das in der Regel durch Iteratoren gelöst wird. Ein Iterator ist eine klar definierte Schnittstelle, mit der du durch eine Reihe von Werten eines Produzenten schreiten kannst. Die JS-Schnittstelle für Iteratoren besteht, wie in den meisten Sprachen, darin, next() jedes Mal aufzurufen, wenn du den nächsten Wert vom Produzenten benötigst.

Wir könnten die Standard-Iterator-Schnittstelle für unseren Zahlenreihenproduzenten implementieren:

var something = (function(){
    var nextVal;

    return {
        // needed for `for..of` loops
        [Symbol.iterator]: function(){ return this; },

        // standard iterator interface method
        next: function(){
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            return { done:false, value:nextVal };
        }
    };
})();

something.next().value;     // 1
something.next().value;     // 9
something.next().value;     // 33
something.next().value;     // 105
Hinweis

Warum wir den [Symbol.iterator]: .. Teil dieses Codeschnipsels brauchen, werden wir in "Iterables" erklären . Syntaktisch gesehen sind jedoch zwei ES6-Funktionen im Spiel. Erstens wird die Syntax von [ .. ] als "computed property name" bezeichnet (siehe den Titel " this & Object Prototypes " in dieser Serie). Es ist eine Möglichkeit, in einer Objektliteraldefinition einen Ausdruck anzugeben und das Ergebnis dieses Ausdrucks als Eigenschaftsnamen zu verwenden. Symbol.iterator ist einer der vordefinierten speziellenSymbol Werte von ES6 (siehe den Titel ES6 & Beyond dieser Serie).

Der Aufruf next() gibt ein Objekt mit zwei Eigenschaften zurück: done ist einboolean Wert, der den vollständigen Status des Iteratorssignalisiert; valueenthält den Iterationswert.

ES6 fügt außerdem die for..of Schleife hinzu, was bedeutet, dass ein Standard-Iteratorautomatisch mit nativer Schleifensyntax konsumiert werden kann:

for (var v of something) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969
Hinweis

Da unser something Iterator immer done:false zurückgibt, würde diese for..of Schleife ewig laufen, weshalb wir die breakBedingung eingebaut haben. Es ist völlig in Ordnung, wenn Iteratoren nie enden, aber es gibt auch Fälle, in denen der Iterator über eine endliche Menge von Werten läuft und schließlich ein done:true zurückgibt.

Die Schleife for..of ruft bei jeder Iteration automatisch next() auf - sie übergibt keine Werte an next()- und wird automatisch beendet, wenn sie ein done:true erhält. Sie ist sehr praktisch, um eine Schleife über eine Reihe von Daten zu ziehen.

Natürlich könntest du auch manuell eine Schleife über die Iteratoren ziehen, next() aufrufen und auf die Bedingung done:true prüfen, um zu wissen, wann du aufhören musst:

for (
    var ret;
    (ret = something.next()) && !ret.done;
) {
    console.log( ret.value );

    // don't let the loop run forever!
    if (ret.value > 500) {
        break;
    }
}
// 1 9 33 105 321 969
Hinweis

Dieser manuelle for Ansatz ist sicherlich hässlicher als die ES6for..of Schleifensyntax, aber sein Vorteil ist, dass er dir die Möglichkeit gibt, Werte an die next(..) Aufrufe zu übergeben, falls nötig.

Du kannst nicht nur eigene Iteratoren erstellen, sondern viele eingebaute Datenstrukturen in JS (ab ES6), wie array, haben auchStandard-Iteratoren:

var a = [1,3,5,7,9];

for (var v of a) {
    console.log( v );
}
// 1 3 5 7 9

Die Schleife for..of fragt a nach seinem Iterator und benutzt ihn automatisch, um über die Werte von azu iterieren.

Hinweis

Es mag seltsam erscheinen, dass ES6 das nicht tut, aber reguläre objectwerden absichtlich nicht mit einem Standard-Iterator geliefert, so wie array. Die Gründe dafür gehen tiefer, als wir hier behandeln werden. Wenn du nur über die Eigenschaften eines Objekts iterieren willst (ohne besondere Garantie für die Reihenfolge), gibt Object.keys(..) einen array zurück, der dann wie for (var k of Object.keys(obj)) { .. verwendet werden kann. Eine solche for..of -Schleife über die Schlüssel eines Objekts wäre ähnlich wie eine for..in -Schleife, mit dem Unterschied, dass Object.keys(..) die Eigenschaften aus der [[Prototype]] -Kette nicht mit einbezieht, während for..in dies tut (siehe den Titel this & Object Prototypes dieser Serie).

Iterables

Das Objekt something in unserem Beispiel wird als Iterator bezeichnet, da es die Methode next() in seiner Schnittstelle hat. Ein eng verwandter Begriff ist iterable, d.h. ein object, das einen Iteratorenthält, der über seine Werte iterieren kann.

Seit ES6 kann man einen Iterator aus einer Iterable abrufen, indem man eine Funktion in der Iterable aufruft, deren Name der spezielle ES6-Symbolwert Symbol.iterator ist. Wenn diese Funktion aufgerufen wird, gibt sie einen Iterator zurück. Obwohl dies nicht erforderlich ist, sollte in der Regel bei jedem Aufruf ein neuer Iterator zurückgegeben werden.

a im vorherigen Schnipsel ist eine Iterable. Die for..of Schleife ruft automatisch die Funktion Symbol.iterator auf, um einenIterator zu erstellen. Aber wir können die Funktion natürlich auch manuell aufrufen und den Iterator verwenden, den sie zurückgibt:

var a = [1,3,5,7,9];

var it = a[Symbol.iterator]();

it.next().value;    // 1
it.next().value;    // 3
it.next().value;    // 5
..

In der vorherigen Codeauflistung, die something definiert, ist dir vielleicht diese Zeile aufgefallen:

[Symbol.iterator]: function(){ return this; }

Dieses kleine Stück verwirrenden Codes macht den Wert something - die Schnittstelle des Iterators something - auchzu einer Iterable; er ist jetzt sowohl eine Iterable als auch ein Iterator. Dann übergeben wir something an diefor..of Schleife:

for (var v of something) {
    ..
}

Die Schleife for..of erwartet, dass something eine Iterable ist, also sucht sie die Funktion Symbol.iterator und ruft sie auf. Wir haben diese Funktion einfach als return this definiert, so dass sie sich selbst zurückgibt und die for..ofSchleife nichts davon mitbekommt.

Generator Iterator

Wenden wir uns nun wieder den Generatoren zu, und zwar im Zusammenhang mitIteratoren. Ein Generator kann als Produzent von Werten betrachtet werden, die wir über die next() Aufrufe der Iteratorschnittstelle nacheinander extrahieren.

Ein Generator selbst ist also technisch gesehen keine Iterable, obwohl er sehr ähnlich ist - wenn du den Generator ausführst, bekommst du einen Iteratorzurück:

function *foo(){ .. }

var it = foo();

Wir können den Erzeuger der unendlichen Zahlenreihe something von vorhin mit einem Generator umsetzen, etwa so:

function *something() {
    var nextVal;

    while (true) {
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        yield nextVal;
    }
}
Hinweis

Eine while..true -Schleife wäre in einem echten JS-Programm normalerweise eine sehr schlechte Sache, zumindest wenn sie kein break oderreturn enthält, da sie wahrscheinlich ewig und synchron laufen und die Benutzeroberfläche des Browsers blockieren/verriegeln würde. In einem Generator ist eine solche Schleife jedoch in der Regel völlig in Ordnung, wenn sie ein yield enthält, da der Generator bei jeder Iteration eine Pause macht und yieldzurück zum Hauptprogramm und/oder zur Warteschlange der Ereignisschleife sendet. Um es kurz zu machen: "Generatoren bringen diewhile..true zurück in die JS-Programmierung!"

Das ist doch viel sauberer und einfacher, oder? Da der Generator bei jedem yield eine Pause einlegt, bleibt der Zustand (Scope) der Funktion *something()erhalten, d.h. es ist nicht notwendig, dass die Closure-Boilerplate den Zustand der Variablen über mehrere Aufrufe hinweg beibehält.

Der Code ist nicht nur einfacher - wir müssen keine eigene Iteratorschnittstelleerstellen -, sondern er ist auch vernünftiger, weil er die Absicht klarer zum Ausdruck bringt. Die Schleife while..true sagt uns zum Beispiel, dass der Generator für immer laufen soll - er soll so lange Werte erzeugen, wie wir sie abfragen.

Und jetzt können wir unseren neuen *something() Generator mit einerfor..of Schleife verwenden, und du wirst sehen, dass er im Grunde genauso funktioniert:

for (var v of something()) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969

Aber überspringe nicht for (var v of something()) ..! Wir haben nicht einfach something als Wert referenziert, wie in früheren Beispielen, sondern den *something() Generator aufgerufen, um seinen Iterator für diefor..of Schleife zu erhalten.

Wenn du genau hinschaust, ergeben sich aus dieser Interaktion zwischen dem Generator und der Schleife vielleicht zwei Fragen:

  • Warum können wir nicht for (var v of something) .. sagen? Weil somethinghier ein Generator ist, der nicht iterierbar ist. Wir müssensomething() aufrufen, um einen Erzeuger zu konstruieren, über den die for..of Schleife iterieren kann.

  • Der Aufruf von something() erzeugt einen Iterator, aber die for..of Schleife will eine Iterable, richtig? Ja, genau. Der Iterator des Generators hat auch eineSymbol.iterator Funktion, die im Grunde genommen eine return this Funktion ist, genau wie die something Iterable, die wir zuvor definiert haben. Mit anderen Worten: Der Iterator eines Generators ist auch eine Iterable!

Anhalten des Generators

Im vorherigen Beispiel scheint es so, als ob die Iterator-Instanz für den*something() -Generator nach dem Aufruf von break in der Schleife für immer in einem Schwebezustand belassen wurde.

Aber es gibt ein verstecktes Verhalten, das dir das abnimmt. Ein "abnormaler Abschluss" (d.h. ein "vorzeitiger Abbruch") der for..of Schleife - in der Regel verursacht durch eine break, return oder eine nicht abgefangene Ausnahme - sendet ein Signal an den Iterator des Generators, damit dieser abbricht.

Hinweis

Technisch gesehen sendet die Schleife for..of dieses Signal auch an denIterator, wenn die Schleife normal beendet wird. Für einen Generator ist das im Grunde genommen überflüssig, da der Iterator des Generators zuerst fertig sein muss, damit die for..of Schleife abgeschlossen werden kann. BenutzerdefinierteIteratoren möchten jedoch möglicherweise dieses zusätzliche Signal von den Verbrauchern derfor..of Schleife erhalten.

Während eine for..of -Schleife dieses Signal automatisch sendet, kannst du das Signal auch manuell an einen Iterator senden; das machst du, indem dureturn(..) aufrufst.

Wenn du eine try..finally Klausel innerhalb des Generators angibst, wird sie immer ausgeführt, auch wenn der Generator extern beendet wird. Das ist nützlich, wenn du Ressourcen aufräumen musst (Datenbankverbindungen usw.):

function *something() {
    try {
        var nextVal;

        while (true) {
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            yield nextVal;
        }
    }
    // cleanup clause
    finally {
        console.log( "cleaning up!" );
    }
}

Das frühere Beispiel mit break in der for..of Schleife wird diefinally Klausel auslösen. Du könntest aber stattdessen die Iterator-Instanz des Generators manuell von außen mit return(..) beenden:

var it = something();
for (var v of it) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        console.log(
            // complete the generator's iterator
            it.return( "Hello World" ).value
        );
        // no `break` needed here
    }
}
// 1 9 33 105 321 969
// cleaning up!
// Hello World

Wenn wir it.return(..) aufrufen, wird der Generator sofort beendet, der natürlich die finally Klausel ausführt. Außerdem wird die zurückgegebenevalue auf den Wert gesetzt, den du an return(..) übergeben hast, so dass"Hello World" direkt wieder herauskommt. Außerdem müssen wir jetzt keinebreak mehr einfügen, weil der Iterator des Generators auf done:true gesetzt ist, so dass die for..of Schleife bei ihrer nächsten Iteration beendet wird.

Generatoren verdanken ihren Namen vor allem dieser Verwendung von konsumierten Werten. Aber auch das ist nur eine der Verwendungen von Generatoren, und ehrlich gesagt nicht einmal die wichtigste, mit der wir uns in diesem Buch beschäftigen.

Nachdem wir nun die Funktionsweise der Generatoren besser verstanden haben, können wir uns nun der Frage zuwenden, wie Generatoren auf die asynchrone Gleichzeitigkeit angewendet werden.

Asynchrone Iteration von Generatoren

Was haben Generatoren mit asynchronen Codierungsmustern, der Behebung von Problemen mit Callbacks und Ähnlichem zu tun? Lass uns diese wichtige Frage beantworten.

Wir sollten eines unserer Szenarien aus Kapitel 3 noch einmal durchgehen. Erinnern wir uns an den Callback-Ansatz:

function foo(x,y,cb) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        cb
    );
}

foo( 11, 31, function(err,text) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( text );
    }
} );

Wenn wir die gleiche Aufgabenablaufsteuerung mit einem Generator ausdrücken wollten, könnten wir das tun:

function foo(x,y) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        function(err,data){
            if (err) {
                // throw an error into `*main()`
                it.throw( err );
            }
            else {
                // resume `*main()` with received `data`
                it.next( data );
            }
        }
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

var it = main();

// start it all up!
it.next();

Auf den ersten Blick ist dieses Snippet länger und sieht vielleicht etwas komplexer aus als das Callback-Snippet vor ihm. Aber lass dich von diesem Eindruck nicht beirren. Das Generator-Snippet ist tatsächlich vielbesser! Aber es gibt eine Menge, das wir erklären müssen.

Schauen wir uns zunächst diesen Teil des Codes an, der der wichtigste ist:

var text = yield foo( 11, 31 );
console.log( text );

Denke einen Moment darüber nach, wie dieser Code funktioniert. Wir rufen eine normale Funktion foo(..) auf und sind offensichtlich in der Lage, die text vom Ajax-Aufruf zurückzubekommen, obwohl dieser asynchron ist.

Wie ist das möglich? Wenn du dich an den Anfang von Kapitel 1 erinnerst, hatten wir fast den gleichen Code:

var data = ajax( "..url 1.." );
console.log( data );

Und dieser Code hat nicht funktioniert! Kannst du den Unterschied erkennen? Es ist die yield, die in einem Generator verwendet wird.

Das ist die Magie! So können wir scheinbar blockierenden, synchronen Code haben, der aber nicht das ganze Programm blockiert, sondern nur den Code im Generator selbst.

In yield foo(11,31) wird zuerst der Aufruf foo(11,31) gemacht, der nichts zurückgibt (auch bekannt als undefined), also machen wir einen Aufruf, um Daten anzufordern, aber eigentlich machen wir dann yield undefined. Das ist in Ordnung, denn der Code verlässt sich momentan nicht auf einen yieldWert, um etwas Interessantes zu tun. Wir werden diesen Punkt später im Kapitel noch einmal aufgreifen.

Wir verwenden yield hier nicht im Sinne einer Nachrichtenübermittlung, sondern nur im Sinne einer Flusskontrolle, um zu pausieren/blockieren. Tatsächlich werden Nachrichten weitergegeben, aber nur in eine Richtung, nachdem der Generator wieder gestartet wurde.

Der Generator hält also bei yield an und stellt die Frage: "Welchen Wert soll ich zurückgeben, um ihn der Variablen text zuzuweisen?" Wer soll diese Frage beantworten?

Schau dir foo(..) an. Wenn die Ajax-Anfrage erfolgreich ist, rufen wir auf:

it.next( data );

Das bedeutet, dass unser angehaltener Ausdruck yield diesen Wert direkt erhält und dass dieser Wert beim Neustart des Generatorcodes der lokalen Variablen text zugewiesen wird.

Ziemlich cool, oder?

Tritt einen Schritt zurück und überlege, was das bedeutet. Der Code im Generator sieht völlig synchron aus (abgesehen vom Schlüsselwort yieldselbst), aber hinter den Kulissen von foo(..) können die Vorgänge asynchron ausgeführt werden.

Das ist großartig! Das ist eine fast perfekte Lösung für unser Problem, dass Callbacks nicht in der Lage sind, Asynchronität auf eine sequenzielle, synchrone Weise auszudrücken, mit der unser Gehirn etwas anfangen kann.

Im Grunde genommen abstrahieren wir die Asynchronität als Implementierungsdetail, damit wir synchron/sequenziell über unsere Ablaufsteuerung nachdenken können: "Stelle eine Ajax-Anfrage und drucke die Antwort aus, wenn sie fertig ist." Natürlich haben wir nur zwei Schritte in der Ablaufsteuerung ausgedrückt, aber diese Fähigkeit lässt sich unbegrenzt erweitern, so dass wir beliebig viele Schritte ausdrücken können.

Tipp

Das ist eine so wichtige Erkenntnis, dass du die letzten drei Absätze noch einmal lesen solltest, damit du sie verinnerlichen kannst!

Synchrone Fehlerbehandlung

Aber der vorhergehende Generatorcode hat noch mehr Gutes für uns zu bieten. Richten wir unsere Aufmerksamkeit auf die try..catch innerhalb des Generators:

try {
    var text = yield foo( 11, 31 );
    console.log( text );
}
catch (err) {
    console.error( err );
}

Wie funktioniert das? Der Aufruf von foo(..) wird asynchron ausgeführt, und schlägt try..catch nicht fehl, um asynchrone Fehler abzufangen, wie wir in Kapitel 3 gesehen haben?

Wir haben bereits gesehen, wie yield die Zuweisungsanweisung pausieren lässt, um zu warten, bis foo(..) fertig ist, damit die fertige Antwort text zugewiesen werden kann. Das Tolle daran ist, dass dieses yield Pausieren dem Generator aucherlaubt, catch einen Fehler zu melden. Diesen Fehler geben wir mit diesem Teil des früheren Code-Listings an den Generator weiter:

if (err) {
    // throw an error into `*main()`
    it.throw( err );
}

Die yield-pause Natur von Generatoren bedeutet, dass wir nicht nur synchron aussehende return Werte von asynchronen Funktionsaufrufen erhalten, sondern auch synchron catch Fehler von diesen asynchronen Funktionsaufrufen!

Wir haben also gesehen, dass wir Fehler in einen Generator hineinwerfen können, aber was ist mit Fehlern, die aus einem Generator herauskommen? Genau so, wie du es erwartet hast:

function *main() {
    var x = yield "Hello World";

    yield x.toLowerCase();  // cause an exception!
}

var it = main();

it.next().value;            // Hello World

try {
    it.next( 42 );
}
catch (err) {
    console.error( err );   // TypeError
}

Natürlich hätten wir auch manuell einen Fehler mit throw ..auslösen können, anstatt eine Ausnahme zu machen.

Wir können sogar catch denselben Fehler, den wir throw(..) in den Generator eingeben, so dass der Generator die Chance hat, den Fehler zu behandeln, aber wenn er es nicht tut, muss der Iterator-Code ihn behandeln:

function *main() {
    var x = yield "Hello World";

    // never gets here
    console.log( x );
}

var it = main();

it.next();

try {
    // will `*main()` handle this error? we'll see!
    it.throw( "Oops" );
}
catch (err) {
    // nope, didn't handle it!
    console.error( err );           // Oops
}

Die synchrone Fehlerbehandlung (über try..catch) mit asynchronem Code ist ein großer Gewinn für die Lesbarkeit und Verständlichkeit.

Generatoren + Versprechen

In unserer vorherigen Diskussion haben wir gezeigt, wie Generatoren asynchron iteriert werden können. Das ist ein großer Fortschritt in der sequentiellen Vernunft gegenüber dem Spaghetti-Wirrwarr der Callbacks. Aber wir haben etwas sehr Wichtiges verloren: die Vertrauenswürdigkeit und Zusammensetzbarkeit von Promises (siehe Kapitel 3)!

Keine Sorge - das können wir zurückbekommen. Das Beste aus allen Welten in ES6 ist die Kombination von Generatoren (synchron aussehender asynchroner Code) mit Promises (vertrauenswürdig und zusammensetzbar).

Aber wie?

Erinnere dich an den Promise-basierten Ansatz aus Kapitel 3 für unser laufendes Ajax-Beispiel:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

foo( 11, 31 )
.then(
    function(text){
        console.log( text );
    },
    function(err){
        console.error( err );
    }
);

In unserem früheren Generatorcode für das laufende Ajax-Beispiel gab foo(..)nichts zurück (undefined), und unser Iterator-Steuerungscode kümmerte sich nicht um diesen yielded-Wert.

Aber hier gibt die Promise-aware foo(..) ein Versprechen zurück, nachdem sie den Ajax-Aufruf gemacht hat. Das legt nahe, dass wir mitfoo(..) ein Versprechen konstruieren und es dann mit yield vom Generator abrufen könnten, damit derIterator-Kontrollcode dieses Versprechen erhält.

Aber was soll der Iterator mit dem Versprechen machen?

Er sollte darauf warten, dass das Versprechen aufgelöst wird (Erfüllung oder Ablehnung) und dann entweder den Generator mit der Erfüllungsmeldung fortsetzen oder einen Fehler mit dem Ablehnungsgrund in den Generator werfen.

Lass mich das wiederholen, weil es so wichtig ist. Der natürliche Weg, um das Beste aus Promises und Generatoren herauszuholen, ist yield eine Promise und die Verdrahtung dieser Promise, um den Iterator des Generators zu steuern.

Lass es uns ausprobieren! Zuerst setzen wir den Promise-aware foo(..)mit dem Generator *main() zusammen:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

Die wichtigste Erkenntnis dieses Refactors ist, dass der Code in*main() überhaupt nicht geändert werden musste! Innerhalb des Generators sind die Werte, die yieldausgibt, nur ein undurchsichtiges Implementierungsdetail, so dass wir nicht einmal wissen, dass es passiert, und uns auch nicht darum kümmern müssen.

Aber wie werden wir *main() jetzt ausführen? Wir müssen noch etwas an der Implementierung arbeiten, um das yieldVersprechen zu empfangen und so zu verdrahten, dass es den Generator nach der Auflösung wieder aufnimmt. Wir beginnen damit, dies manuell zu versuchen:

var it = main();

var p = it.next().value;

// wait for the `p` promise to resolve
p.then(
    function(text){
        it.next( text );
    },
    function(err){
        it.throw( err );
    }
);

Eigentlich war das doch gar nicht so schmerzhaft, oder?

Dieses Snippet sollte sehr ähnlich aussehen wie das, was wir zuvor mit dem manuell verdrahteten Generator gemacht haben, der durch den Error-First-Callback gesteuert wird. Anstelle eines if (err) { it.throw.. trennt das Versprechen die Erfüllung (Erfolg) und die Ablehnung (Misserfolg) bereits für uns auf, aber ansonsten ist die Iteratorsteuerungidentisch.

Jetzt haben wir ein paar wichtige Details übersehen.

Vor allem haben wir uns die Tatsache zunutze gemacht, dass wir wussten, dass*main() nur einen Promise-fähigen Schritt in sich hat. Was wäre, wenn wir in der Lage sein wollten, einen Generator mit Versprechen zu steuern, egal wie viele Schritte er hat? Wir wollen sicher nicht für jeden Generator die Promise-Kette manuell anders schreiben! Viel schöner wäre es, wenn es eine Möglichkeit gäbe, die Iterationskontrolle zu wiederholen (auch bekannt als Schleife) und jedes Mal, wenn ein Versprechen herauskommt, auf seine Auflösung zu warten, bevor man fortfährt.

Was ist, wenn der Generator während des Aufrufs von it.next(..) einen Fehler ausgibt (absichtlich oder versehentlich)? Sollen wir den Vorgang abbrechen oder sollen wir catch aufrufen und ihn direkt zurücksenden? Und was ist, wenn wirit.throw(..) eine Promise-Ablehnung in den Generator schicken, diese aber nicht verarbeitet wird und direkt wieder herauskommt?

Versprechungsbewusster Generator Läufer

Je mehr du anfängst, diesen Weg zu erforschen, desto mehr merkst du: "Wow, es wäre toll, wenn es einfach ein Hilfsmittel gäbe, das das für mich erledigt." Und du hast absolut Recht. Da es sich um ein so wichtiges Muster handelt und du es nicht falsch machen willst (oder dich damit abmühst, es immer und immer wieder zu wiederholen), ist es am besten, wenn du ein Dienstprogramm verwendest, das speziell dafür entwickelt wurde, Versprechenyielding-Generatoren so auszuführen, wie wir es hier gezeigt haben.

Mehrere Promise-Abstraktionsbibliotheken bieten ein solches Hilfsmittel, darunter meine Asynquence-Bibliothek und ihre runner(..), die in Anhang A dieses Buches besprochen werden.

Aber zum Lernen und zur Veranschaulichung definieren wir einfach unser eigenes eigenständiges Dienstprogramm, das wir run(..) nennen:

// thanks to Benjamin Gruenbaum (@benjamingr on GitHub) for
// big improvements here!
function run(gen) {
    var args = [].slice.call( arguments, 1), it;

    // initialize the generator in the current context
    it = gen.apply( this, args );

    // return a promise for the generator completing
    return Promise.resolve()
        .then( function handleNext(value){
            // run to the next yielded value
            var next = it.next( value );

            return (function handleResult(next){
                // generator has completed running?
                if (next.done) {
                    return next.value;
                }
                // otherwise keep going
                else {
                    return Promise.resolve( next.value )
                        .then(
                            // resume the async loop on
                            // success, sending the resolved
                            // value back into the generator
                            handleNext,

                            // if `value` is a rejected
                            // promise, propagate error back
                            // into the generator for its own
                            // error handling
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw( err )
                                )
                                .then( handleResult );
                            }
                        );
                }
            })(next);
        } );
}

Wie du siehst, ist das ein bisschen komplexer, als du wahrscheinlich selbst schreiben möchtest, und du möchtest diesen Code nicht für jeden Generator wiederholen, den du verwendest. Daher ist ein Hilfsprogramm oder eine Bibliothek definitiv der richtige Weg. Trotzdem solltest du dir ein paar Minuten Zeit nehmen, um den Code zu studieren, damit du ein besseres Gefühl dafür bekommst, wie du die Verhandlungen zwischen Generator und Promise führen kannst.

Wie würdest du run(..) mit *main() in unserem laufenden Ajax-Beispiel verwenden?

function *main() {
    // ..
}

run( main );

Das war's! So wie wir run(..) verdrahtet haben, wird es den Generator, den du ihm übergibst, automatisch und asynchron bis zur Fertigstellung vorantreiben.

Hinweis

Die run(..), die wir definiert haben, gibt ein Versprechen zurück, das aufgelöst wird, sobald der Generator fertig ist, oder eine nicht abgefangene Ausnahme erhält, wenn der Generator sie nicht behandelt. Wir zeigen diese Fähigkeit hier nicht, aber wir werden später in diesem Kapitel darauf zurückkommen.

ES7: async und await?

Das vorangegangene Muster - Generatoren yielding Promises, die dann den Iterator des Generators steuern, um ihn zur Vollendung zu bringen - ist ein so mächtiger und nützlicher Ansatz, dass es schöner wäre, wenn wir es ohne das Durcheinander der Hilfsprogramme der Bibliothek (auch bekannt als run(..)) machen könnten.

Es gibt wahrscheinlich gute Nachrichten an dieser Front. Zum Zeitpunkt des Verfassens dieses Artikels gibt es erste, aber starke Unterstützung für einen Vorschlag für mehr syntaktische Ergänzungen in diesem Bereich für die Zeit nach ES6, etwa ab ES7. Natürlich ist es noch zu früh, um die Details zu garantieren, aber die Chancen stehen nicht schlecht, dass es in etwa so ablaufen wird wie im Folgenden beschrieben:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

async function main() {
    try {
        var text = await foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

main();

Wie du siehst, gibt es keinen Aufruf von run(..) (d.h. du brauchst kein Bibliotheksdienstprogramm!), um main()aufzurufen und zu steuern - sie wird einfach als normale Funktion aufgerufen. Außerdem ist main() nicht mehr als Generatorfunktion deklariert, sondern als eine neue Art von Funktion: async function. Und stattyieldein Promise aufzurufen, fordern wir await auf, es aufzulösen.

async function weiß automatisch, was zu tun ist, wenn du await ein Versprechen gibst - es hält die Funktion an (genau wie bei Generatoren), bis das Versprechen aufgelöst wird. Wir haben es in diesem Snippet nicht gezeigt, aber der Aufruf einer asynchronen Funktion wie main() gibt automatisch ein Versprechen zurück, das aufgelöst wird, sobald die Funktion vollständig beendet ist.

Tipp

Die Syntax von async / await sollte Lesern mit Erfahrung in C# sehr vertraut vorkommen, da sie im Grunde genommen identisch ist.

Der Vorschlag kodiert im Wesentlichen die Unterstützung für das Muster, das wir bereits abgeleitet haben, in einem syntaktischen Mechanismus: die Kombination von Promises mit synchronem Flow Control Code. Das ist das Beste aus beiden Welten und löst praktisch alle wichtigen Probleme, die wir mit Rückrufen beschrieben haben.

Allein die Tatsache, dass es einen solchen ES7-ähnlichen Vorschlag bereits gibt und er bereits Unterstützung und Begeisterung findet, ist ein großer Vertrauensbeweis für die zukünftige Bedeutung dieses asynchronen Musters.

Gleichzeitigkeit von Versprechen in Generatoren

Bisher haben wir nur einen einstufigen asynchronen Ablauf mitPromises und Generatoren gezeigt. Aber in der Realität hat der Code oft viele asynchrone Schritte.

Wenn du nicht aufpasst, kann dich der synchrone Stil der Generatoren dazu verleiten, deine asynchrone Gleichzeitigkeit selbstgefällig zu strukturieren, was zu suboptimalen Leistungsmustern führt. Deshalb wollen wir uns ein wenig Zeit nehmen, um die Optionen zu erkunden.

Stell dir ein Szenario vor, in dem du Daten aus zwei verschiedenen Quellen abrufen, diese Antworten dann für eine dritte Anfrage kombinieren und schließlich die letzte Antwort ausdrucken musst. In Kapitel 3 haben wir ein ähnliches Szenario mit Promises erforscht, aber betrachten wir es noch einmal im Kontext von Generatoren.

Dein erster Instinkt ist vielleicht so etwas wie:

function *foo() {
    var r1 = yield request( "http://some.url.1" );
    var r2 = yield request( "http://some.url.2" );

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

Dieser Code funktioniert zwar, aber in unserem Fall ist er nicht optimal. Kannst du erkennen, warum?

Die Anfragen r1 und r2 können - undsolltenaus Leistungsgründen - gleichzeitig ausgeführt werden, aber in diesem Code werden sie nacheinander ausgeführt; die URL "http://some.url.2" wird erst abgerufen, nachdem die Anfrage "http://some.url.1" beendet ist. Da diese beiden Anfragen unabhängig voneinander sind, wäre es aus Leistungsgründen wahrscheinlich besser, sie gleichzeitig auszuführen.

Aber wie genau würdest du das mit einem Generator und yield machen? Wir wissen, dass yield nur ein einziger Pausenpunkt im Code ist, also kannst du nicht wirklich zwei Pausen gleichzeitig machen.

Die natürlichste und effektivste Antwort ist, den asynchronen Ablauf auf Promises zu stützen, insbesondere auf deren Fähigkeit, Zustände zeitunabhängig zu verwalten (siehe "Zukunftswert" in Kapitel 3).

Der einfachste Ansatz:

function *foo() {
    // make both requests "in parallel"
    var p1 = request( "http://some.url.1" );
    var p2 = request( "http://some.url.2" );

    // wait until both promises resolve
    var r1 = yield p1;
    var r2 = yield p2;

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

Warum ist das anders als der vorherige Schnipsel? Sieh dir an, woyield steht und wo nicht. p1 und p2 sind Versprechen für Ajax-Anfragen, die gleichzeitig (also "parallel") gestellt werden. Es spielt keine Rolle, welche der beiden Anfragen zuerst beendet wird, da die Versprechen ihren aufgelösten Zustand so lange wie nötig aufrechterhalten.

Dann verwenden wir zwei aufeinander folgende yield Anweisungen, um auf die Auflösungen der Versprechen zu warten und diese abzurufen (in r1 bzw. r2). Wennp1 zuerst auflöst, wird yield p1 zuerst fortgesetzt und wartet dann darauf, dassyield p2 fortgesetzt wird. Wenn p2 zuerst auflöst, behält es den Auflösungswert geduldig bei, bis es gefragt wird, aber yield p1 wartet zuerst, bis p1 auflöst.

In beiden Fällen werden p1 und p2 gleichzeitig ausgeführt und müssen in der einen oder anderen Reihenfolge beendet werden, bevor die r3 = yield request.. Ajax-Anfrage gestellt wird.

Wenn dir dieses Modell der Flusskontrolle bekannt vorkommt, ist es im Grunde dasselbe, das wir in Kapitel 3 als Gate-Muster identifiziert haben und das durch das Dienstprogramm Promise.all([ .. ]) ermöglicht wird. Wir könnten die Flusskontrolle also auch so ausdrücken:

function *foo() {
    // make both requests "in parallel," and
    // wait until both promises resolve
    var results = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" )
    ] );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );
Hinweis

Wie wir in Kapitel 3 besprochen haben, können wir sogar ES6 Destrukturierungszuweisungen verwenden, um die var r1 = .. var r2 = .. Zuweisungen zu vereinfachen, mitvar [r1,r2] = results.

Mit anderen Worten: Alle Gleichzeitigkeitsfunktionen von Promises stehen uns im Generator + Promise-Ansatz zur Verfügung. Überall dort, wo du mehr als sequenzielle, asynchrone Ablaufsteuerungsschritte brauchst, sind Promises also die beste Wahl.

Versprechen, Versteckt

Als stilistische Warnung: Sei vorsichtig, wie viel Promise-Logik du in deine Generatoren einbaust. Der Sinn der Verwendung von Generatoren für Asynchronität auf die beschriebene Weise besteht darin, einfachen, sequenziellen, synchron aussehenden Code zu erstellen und die Details der Asynchronität so weit wie möglich vor diesem Code zu verbergen.

Das könnte zum Beispiel ein sauberer Ansatz sein:

// note: normal function, not generator
function bar(url1,url2) {
    return Promise.all( [
        request( url1 ),
        request( url2 )
    ] );
}

function *foo() {
    // hide the Promise-based concurrency details
    // inside `bar(..)`
    var results = yield bar(
        "http://some.url.1",
        "http://some.url.2"
    );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

Innerhalb von *foo() ist es sauberer und klarer, dass wir nur bar(..) bitten, uns etwas results zu besorgen, und dass wir yield darauf warten, dass dies geschieht. Wir müssen uns nicht darum kümmern, dass unter der Haube einePromise.all([ .. ]) Promise-Komposition verwendet wird, um das zu erreichen.

Wir behandeln Asynchronität und auch Promises als Implementierungsdetail.

Das Verstecken deiner Promise-Logik in einer Funktion, die du lediglich von deinem Generator aus aufrufst, ist besonders nützlich, wenn du eine ausgeklügelte Serienablaufsteuerung durchführen willst. Ein Beispiel:

function bar() {
    Promise.all( [
        baz( .. )
        .then( .. ),
        Promise.race( [ .. ] )
    ] )
    .then( .. )
}

Diese Art von Logik ist manchmal erforderlich, und wenn du sie direkt in deine(n) Generator(en) einbaust, hast du den Großteil des Grundes, warum du überhaupt Generatoren verwenden willst, über Bord geworfen. Wir solltensolche Details absichtlich von unserem Generatorcode abstrahieren, damit sie die Aufgabenausdrücke auf höherer Ebene nicht überladen.

Du solltest nicht nur funktionalen und leistungsfähigen Code erstellen, sondern auch darauf achten, dass dein Code so vernünftig und wartbar wie möglich ist.

Hinweis

Abstraktion ist nicht immer eine gesunde Sache für die Programmierung - oft kann sie die Komplexität im Tausch gegen Einfachheit erhöhen. Aber in diesem Fall glaube ich, dass sie für deinen asynchronen Generator + Promise-Code viel gesünder ist als die Alternativen. Wie bei allen anderen Ratschlägen auch, solltest du auf deine spezifische Situation achten und die richtigen Entscheidungen für dich und dein Team treffen.

Generator Delegation

Im vorigen Abschnitt haben wir gezeigt, dass der Aufruf normaler Funktionen innerhalb eines Generators eine nützliche Technik ist, um Implementierungsdetails (wie den asynchronen Promise-Fluss) zu abstrahieren. Der größte Nachteil bei der Verwendung einer normalen Funktion für diese Aufgabe ist jedoch, dass sie sich nach den Regeln für normale Funktionen verhalten muss, d.h. sie kann sich nicht selbst mit yield anhalten, wie es ein Generator kann.

Du könntest dann versuchen, einen Generator von einem anderen Generator aus aufzurufen, indem du unsere run(..) Hilfe benutzt, wie z.B.:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // "delegating" to `*foo()` via `run(..)`
    var r3 = yield run( foo );

    console.log( r3 );
}

run( bar );

Wir führen *foo() innerhalb von *bar() aus, indem wir wieder unser Dienstprogramm run(..) verwenden. Wir machen uns dabei die Tatsache zunutze, dass run(..), das wir zuvor definiert haben, ein Versprechen zurückgibt, das aufgelöst wird, wenn sein Generator zu Ende ausgeführt wird (oder einen Fehler macht). Wenn wir also yield das Versprechen eines anderen run(..) -Aufrufs an eine run(..) -Instanz ausgeben, wird*bar() automatisch angehalten, bis *foo() fertig ist.

Aber es gibt eine noch bessere Möglichkeit, den Aufruf von *foo() in*bar() zu integrieren, und sie heißt yield-delegation. Die spezielle Syntax füryield-delegation lautet: yield * __ (beachte das zusätzliche *). Bevor wir sehen, wie es in unserem Beispiel funktioniert, schauen wir uns ein einfacheres Szenario an:

function *foo() {
    console.log( "`*foo()` starting" );
    yield 3;
    yield 4;
    console.log( "`*foo()` finished" );
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();   // `yield`-delegation!
    yield 5;
}

var it = bar();

it.next().value;    // 1
it.next().value;    // 2
it.next().value;    // `*foo()` starting
                    // 3
it.next().value;    // 4
it.next().value;    // `*foo()` finished
                    // 5
Hinweis

Ähnlich wie in einer früheren Notiz in diesem Kapitel, in der ich erklärt habe, warum ich function *foo() .. statt function* foo() .. bevorzuge, bevorzuge ich auch - anders als die meisten anderen Dokumentationen zu diesem Thema -yield *foo() statt yield* foo(). Die Platzierung von * ist rein stilistisch und liegt in deinem Ermessen. Aber ich finde die Einheitlichkeit des Stils attraktiv.

Wie funktioniert die yield *foo() Delegation?

Zunächst wird durch den Aufruf von foo() ein Iterator erstellt, genau wie wir es bereits gesehen haben. Dann delegiert/überträgt yield * die Kontrolle über die Iteratorinstanz (des aktuellen *bar() Generators) an diesen anderen *foo()Iterator.

Die ersten beiden Aufrufe von it.next() kontrollieren *bar(), aber wenn wir den dritten Aufruf von it.next() machen, startet *foo(), und jetzt kontrollieren wir *foo() statt *bar(). Deshalb nennt man es Delegation -*bar() hat seine Iterationskontrolle an *foo() delegiert.

Sobald die it Iterator-Kontrolle den gesamten *foo()Iterator ausgeschöpft hat, kehrt sie automatisch zur Kontrolle von *bar() zurück.

Nun zurück zum vorherigen Beispiel mit den drei aufeinanderfolgenden Ajax-Anfragen:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // "delegating" to `*foo()` via `yield*`
    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

Der einzige Unterschied zwischen diesem Snippet und der zuvor verwendeten Version ist die Verwendung von yield *foo() anstelle des vorherigen yield run(foo).

Hinweis

yield * gibt dir die Kontrolle über die Iteration, nicht die Kontrolle über den Generator. Wenn du den *foo() Generator aufrufst, delegierst du jetzt yield an seinenIterator. Du kannst aber auch yield-delegieren;yield *[1,2,3] würde den Standard-Iterator für den [1,2,3] Array-Wert verwenden.

Warum Delegieren?

Der Zweck der yield-Delegation ist vor allem die Organisation des Codes und ist in dieser Hinsicht symmetrisch zum normalen Funktionsaufruf.

Stell dir zwei Module vor, die jeweils Methoden foo() undbar() bereitstellen, wobei bar() foo() aufruft. Der Grund dafür, dass die beiden getrennt sind, liegt in der Regel darin, dass die richtige Organisation des Codes für das Programm erfordert, dass sie in getrennten Funktionen stehen. So kann es zum Beispiel vorkommen, dass foo() eigenständig aufgerufen wird, während bar()foo() aufruft.

Aus genau denselben Gründen hilft die Trennung der Generatoren bei der Lesbarkeit, Wartung und Fehlersuche. In dieser Hinsicht istyield * eine syntaktische Abkürzung für die manuelle Iteration über die Schritte von *foo() innerhalb von *bar().

Ein solcher manueller Ansatz wäre besonders kompliziert, wenn die Schritte in*foo() asynchron wären. Deshalb musst du dafür wahrscheinlich das Dienstprogrammrun(..) verwenden. Und wie wir gezeigt haben, macht yield *foo()eine Unterinstanz des run(..) Dienstprogramms (wierun(foo)) überflüssig.

Nachrichten delegieren

Du fragst dich vielleicht, wie diese yield-Delegation nicht nur mit derIteratorsteuerung, sondern auch mit der bidirektionalen Nachrichtenübermittlung funktioniert. Verfolge aufmerksam den Fluss der Nachrichten durch die yield-Delegation:

function *foo() {
    console.log( "inside `*foo()`:", yield "B" );

    console.log( "inside `*foo()`:", yield "C" );

    return "D";
}

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-delegation!
    console.log( "inside `*bar()`:", yield *foo() );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: 4
// outside: F

Achte besonders auf die Bearbeitungsschritte nach dem Aufruf vonit.next(3):

  1. Der Wert 3 wird (über die yield-Delegation in *bar()) an den wartenden yield "C" -Ausdruck innerhalb von *foo() übergeben.

  2. *foo() ruft dann return "D" auf, aber dieser Wert wird nicht vollständig an den Aufruf von it.next(3) zurückgegeben.

  3. Stattdessen wird der Wert "D" als Ergebnis des wartenden Ausdrucksyield *foo() innerhalb von *bar()gesendet - dieser yield-Delegationsausdruck wurde im Wesentlichen angehalten, während *foo() erschöpft war. So landet "D" innerhalb von *bar() und wird dort ausgedruckt.

  4. yield "E" wird innerhalb von *bar() aufgerufen, und der Wert von "E" wird als Ergebnis des Aufrufs von it.next(3) nach außen weitergegeben.

Aus der Perspektive des externen Iterators (it) scheint es keinen Unterschied zu machen, ob der ursprüngliche Generator oder ein delegierter Generator gesteuert wird.

Tatsächlich muss die yield-Delegation nicht einmal an einen anderen Generator gerichtet sein; sie kann einfach an einen allgemeinenIterablen ohne Generator gerichtet sein. Ein Beispiel:

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-delegation to a non-generator!
    console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// outside: C

console.log( "outside:", it.next( 3 ).value );
// outside: D

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: undefined
// outside: E

console.log( "outside:", it.next( 5 ).value );
// inside `*bar()`: 5
// outside: F

Beachte die Unterschiede zwischen diesem und dem vorherigen Beispiel, wo die Nachrichten empfangen/gemeldet wurden.

Am auffälligsten ist, dass der Standard-Iterator array sich nicht um die Nachrichten kümmert, die über die Aufrufe von next(..) gesendet werden, so dass die Werte 2, 3 und 4im Wesentlichen ignoriert werden. Da dieser Iterator keinen explizitenreturn Wert hat (anders als der zuvor verwendete *foo()), erhält der yield *Ausdruck am Ende einen undefined.

Auch Ausnahmen werden delegiert!

Genauso wie yield-delegation Nachrichten transparent in beide Richtungen weitergibt, gehen auch Fehler/Ausnahmen in beide Richtungen:

function *foo() {
    try {
        yield "B";
    }
    catch (err) {
        console.log( "error caught inside `*foo()`:", err );
    }

    yield "C";

    throw "D";
}

function *bar() {
    yield "A";

    try {
        yield *foo();
    }
    catch (err) {
        console.log( "error caught inside `*bar()`:", err );
    }

    yield "E";

    yield *baz();

    // note: can't get here!
    yield "G";
}

function *baz() {
    throw "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// outside: B

console.log( "outside:", it.throw( 2 ).value );
// error caught inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// error caught inside `*bar()`: D
// outside: E

try {
    console.log( "outside:", it.next( 4 ).value );
}
catch (err) {
    console.log( "error caught outside:", err );
}
// error caught outside: F

Einige Dinge, die du in diesem Ausschnitt sehen kannst:

  1. Wenn wir it.throw(2) aufrufen, sendet es die Fehlermeldung 2 an*bar(), das sie an *foo() delegiert, das sie dann catches und behandelt sie anständig. Dann sendet yield "C" "C" als Rückmeldung value aus dem Aufruf von it.throw(2) zurück.

  2. Der "D" Wert, der als nächstes thrown von *foo() gesendet wird, wird an *bar() weitergegeben, das catchihn erhält und ihn anständig behandelt. Dann sendetyield "E" "E" als Rückgabewert von value aus dem Aufruf vonit.next(3) zurück.

  3. Als Nächstes wird die Ausnahme thrown von *baz() nicht in *bar()abgefangen - obwohl wir catch sie außerhalb abgefangen haben - so dass sowohl *baz() als auch *bar() in einen abgeschlossenen Zustand versetzt werden. Nach diesem Snippet kannst du den Wert von "G" nicht mehr mit einem oder mehreren nachfolgenden Aufrufen von next(..) herausholen - sie geben nur undefined für value zurück.

Delegieren von Asynchronität

Kommen wir nun zurück zu unserem früheren yield-Delegationsbeispiel mit den mehreren aufeinanderfolgenden Ajax-Anfragen:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

Anstatt yield run(foo) innerhalb von *bar() aufzurufen, rufen wir einfachyield *foo() auf.

In der vorherigen Version dieses Beispiels wurde der Promise-Mechanismus (gesteuert von run(..)) verwendet, um den Wert vonreturn r3 in *foo() an die lokale Variable r3 in *bar() zu übertragen. Jetzt wird dieser Wert direkt über die Mechanik von yield * zurückgegeben.

Ansonsten ist das Verhalten so ziemlich identisch.

Rekursion delegieren

Natürlich kann yield-delegation so vielen Delegationsschritten folgen, wie du verkabelt hast. Du könntest sogar yield-delegation für eine asynchrone Generatorrekursionverwenden - einGenerator yield-delegiert an sich selbst:

function *foo(val) {
    if (val > 1) {
        // generator recursion
        val = yield *foo( val - 1 );
    }

    return yield request( "http://some.url/?v=" + val );
}

function *bar() {
    var r1 = yield *foo( 3 );
    console.log( r1 );
}

run( bar );
Hinweis

Unser run(..) Dienstprogramm hätte auch mitrun( foo, 3 ) aufgerufen werden können, da es zusätzliche Parameter unterstützt, die bei der Initialisierung des Generators mitgegeben werden. Wir haben hier jedoch ein parameterloses *bar() verwendet, um die Flexibilität von yield * zu unterstreichen.

Welche Verarbeitungsschritte folgen aus diesem Code? Warte, das wird ziemlich kompliziert, um es im Detail zu beschreiben:

  1. run(bar) schaltet den Generator *bar() ein.

  2. foo(3) erstellt einen Iterator für *foo(..) und übergibt 3 als seinen Parameterval.

  3. Denn 3 > 1, foo(2) erstellt einen weiteren Iterator und übergibt2 als seinen val Parameter.

  4. Denn 2 > 1, foo(1) erstellt einen weiteren Iterator und übergibt 1 als seinen val Parameter.

  5. 1 > 1 ist false, also rufen wir als Nächstes request(..) mit dem Wert 1auf und bekommen ein Versprechen für diesen ersten Ajax-Aufruf zurück.

  6. Dieses Versprechen wird unter yieldveröffentlicht, das an die *foo(2)Generatorinstanz zurückgegeben wird.

  7. Die yield * gibt das Versprechen an die *foo(3)Generatorinstanz zurück. Eine weitere yield * gibt das Versprechen an die*bar() Generatorinstanz weiter. Und wieder gibt ein yield * das Versprechen an das run(..) Dienstprogramm weiter, das auf dieses Versprechen (für die erste Ajax-Anfrage) wartet, um fortzufahren.

  8. Wenn das Versprechen aufgelöst wird, wird seine Erfüllungsnachricht an die*bar() gesendet, die über die yield * in die *foo(3)Instanz gelangt, die wiederum über die yield * in die *foo(2)Generatorinstanz gelangt, die wiederum über die yield * in die normale yield Instanz, die in der *foo(3) Generatorinstanz wartet.

  9. Die Ajax-Antwort des ersten Aufrufs wird jetzt sofort returnvon der Generatorinstanz *foo(3) gesendet, die diesen Wert als Ergebnis des Ausdrucks yield * in der Instanz *foo(2) zurücksendet und der lokalen Variable val zuweist.

  10. Innerhalb von *foo(2) wird eine zweite Ajax-Anfrage mit request(..) gestellt, deren Versprechen yieldan die Instanz *foo(1) zurückgegeben wird und dannyield * bis zu run(..) weitergegeben wird (wieder Schritt 7). Wenn das Versprechen aufgelöst wird, wird die zweite Ajax-Antwort zurück in die Instanz des *foo(2) -Generators übertragen und der lokalen Variableval zugewiesen.

  11. Schließlich wird die dritte Ajax-Anfrage mit request(..) gestellt, ihr Versprechen geht raus an run(..) und dann kommt ihr Auflösungswert den ganzen Weg zurück, der dann returned ist, so dass er zurück zum wartenden yield * Ausdruck in *bar() kommt.

Puh! Eine Menge verrücktes mentales Jonglieren, was? Vielleicht solltest du dir das noch ein paar Mal durchlesen und dann einen Snack essen gehen, um den Kopf frei zu bekommen!

Generator-Gleichzeitigkeit

Wie wir bereits in Kapitel 1 und in diesem Kapitel besprochen haben, können zwei gleichzeitig ablaufende "Prozesse" ihre Operationen miteinander verschränken, was in vielen Fällen (Wortspiel beabsichtigt) zu sehr mächtigen Asynchronitätsausdrücken führen kann.

Ehrlich gesagt, haben unsere früheren Beispiele für die Gleichzeitigkeit mehrerer Generatoren gezeigt, wie man es wirklich verwirrend machen kann. Aber wir haben angedeutet, dass es Orte gibt, an denen diese Fähigkeit sehr nützlich ist.

Erinnere dich an ein Szenario aus Kapitel 1, in dem zwei verschiedene Ajax-Antwort-Handler gleichzeitig miteinander koordiniert werden mussten, um sicherzustellen, dass die Datenkommunikation nicht zu einer Wettlaufsituation führt. Wir haben die Antworten wie folgt in das Array res eingefügt:

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

Aber wie können wir mehrere Generatoren gleichzeitig für dieses Szenario nutzen?

// `request(..)` is a Promise-aware Ajax utility

var res = [];

function *reqData(url) {
    res.push(
        yield request( url )
    );
}
Hinweis

Wir werden hier zwei Instanzen des Generators *reqData(..) verwenden, aber es gibt keinen Unterschied zu einer einzelnen Instanz von zwei verschiedenen Generatoren; beide Ansätze werden auf die gleiche Weise begründet. Wir werden gleich sehen, wie sich zwei verschiedene Generatoren koordinieren.

Anstatt die Zuweisungen von res[0] und res[1]manuell zu sortieren, verwenden wir eine koordinierte Reihenfolge, damit res.push(..)die Werte in der erwarteten und vorhersehbaren Reihenfolge einfügt. Die ausgedrückte Logik sollte sich dadurch ein bisschen sauberer anfühlen.

Aber wie werden wir diese Interaktion tatsächlich organisieren? Zuerst machen wir es einfach manuell, mit Promises:

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next();
var p2 = it2.next();

p1
.then( function(data){
    it1.next( data );
    return p2;
} )
.then( function(data){
    it2.next( data );
} );

*reqData(..)Die beiden Instanzen werden beide gestartet, um ihre Ajax-Anfragen zu stellen, und dann mit yield pausiert. Dann entscheiden wir uns, die erste Instanz fortzusetzen, wenn p1 aufgelöst wird, und die Auflösung von p2wird die zweite Instanz neu starten. Auf diese Weise stellen wir mit der Promise-Orchestrierung sicher, dass res[0] die erste Antwort und res[1] die zweite Antwort erhält.

Aber ehrlich gesagt, ist das sehr manuell und lässt die Generatoren nicht wirklich selbst orchestrieren, wo die wahre Macht liegen kann. Versuchen wir es auf eine andere Art:

// `request(..)` is a Promise-aware Ajax utility

var res = [];

function *reqData(url) {
    var data = yield request( url );

    // transfer control
    yield;

    res.push( data );
}

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it.next();
var p2 = it.next();

p1.then( function(data){
    it1.next( data );
} );

p2.then( function(data){
    it2.next( data );
} );

Promise.all( [p1,p2] )
.then( function(){
    it1.next();
    it2.next();
} );

Okay, das ist schon etwas besser (aber immer noch manuell!), denn jetzt laufen die beiden Instanzen von *reqData(..) wirklich gleichzeitig und (zumindest für den ersten Teil) unabhängig voneinander.

Im vorigen Schnipsel hat die zweite Instanz ihre Daten erst erhalten, nachdem die erste Instanz vollständig fertig war. Hier jedoch erhalten beide Instanzen ihre Daten, sobald ihre jeweiligen Antworten zurückkommen, und dann führt jede Instanz eine weitere yield zur Kontrollübertragung durch. Wir entscheiden dann, in welcher Reihenfolge wir sie imPromise.all([ .. ]) Handler fortsetzen.

Was vielleicht nicht so offensichtlich ist, ist die Tatsache, dass dieser Ansatz aufgrund der Symmetrie eine einfachere Form für ein wiederverwendbares Dienstprogramm nahelegt. Wir können es noch besser machen: Stellen wir uns ein Dienstprogramm namens runAll(..) vor:

// `request(..)` is a Promise-aware Ajax utility

var res = [];

runAll(
    function*(){
        var p1 = request( "http://some.url.1" );

        // transfer control
        yield;

        res.push( yield p1 );
    },
    function*(){
        var p2 = request( "http://some.url.2" );

        // transfer control
        yield;

        res.push( yield p2 );
    }
);
Hinweis

Wir verzichten auf eine Auflistung des Codes für runAll(..), da er nicht nur lang genug ist, um den Text zu überfrachten, sondern auch eine Erweiterung der Logik darstellt, die wir bereits in run(..) implementiert haben. Als ergänzende Übung für den Leser kannst du versuchen, den Code von run(..) so weiterzuentwickeln, dass er so funktioniert wie der von runAll(..). Außerdem bietet meineAsynquence-Bibliothek ein bereits erwähntes runner(..)Dienstprogramm, in dem diese Art von Fähigkeit bereits eingebaut ist und das in Anhang A dieses Buches besprochen wird.

So würde die Verarbeitung innerhalb von runAll(..) funktionieren:

  1. Der erste Generator erhält ein Versprechen für die erste Ajax-Antwort von"http://some.url.1", dann yields Kontrolle zurück an das Dienstprogramm runAll(..).

  2. Der zweite Generator läuft und macht das Gleiche für"http://some.url.2", yielding control back to the runAll(..)utility.

  3. Der erste Generator wird fortgesetzt, und dann gibt yieldsein Versprechen p1 ab. Das Dienstprogramm runAll(..) tut in diesem Fall dasselbe wie unser vorherigesrun(..), indem es auf die Auflösung des Versprechens wartet und dann denselben Generator wieder aufnimmt (keine Kontrollübertragung!). Wenn p1 aufgelöst wird, setzt runAll(..)den ersten Generator mit diesem Auflösungswert fort undres[0] erhält seinen Wert. Wenn der erste Generator dann beendet wird, ist das eine implizite Übergabe der Kontrolle.

  4. Der zweite Generator wird fortgesetzt, yieldgibt sein Versprechen p2 ab und wartet darauf, dass es aufgelöst wird. Sobald dies der Fall ist, setzt runAll(..) den zweiten Generator mit diesem Wert fort und res[1] wird gesetzt.

In diesem Beispiel verwenden wir eine äußere Variable namens res, um die Ergebnisse der beiden verschiedenen Ajax-Antworten zu speichern - unsere Gleichzeitigkeitskoordination macht das möglich.

Es könnte jedoch sehr hilfreich sein, runAll(..) weiter auszubauen, um einen inneren Variablenraum für mehrere Generatorinstanzen zur Verfügung zu stellen, z. B. ein leeres Objekt, das wir im Folgenden data nennen. Außerdem könnte es Werte, die nicht zu den Versprechen gehören, an den nächsten Generator weitergeben ( yield).

Bedenke:

// `request(..)` is a Promise-aware Ajax utility

runAll(
    function*(data){
        data.res = [];

        // transfer control (and message pass)
        var url1 = yield "http://some.url.2";

        var p1 = request( url1 ); // "http://some.url.1"

        // transfer control
        yield;

        data.res.push( yield p1 );
    },
    function*(data){
        // transfer control (and message pass)
        var url2 = yield "http://some.url.1";

        var p2 = request( url2 ); // "http://some.url.2"

        // transfer control
        yield;

        data.res.push( yield p2 );
    }
);

In dieser Formulierung koordinieren die beiden Generatoren nicht nur die Kontrollübertragung, sondern kommunizieren tatsächlich miteinander, sowohl über data.res als auch über die yielded Nachrichten, die url1 undurl2 Werte austauschen. Das ist unglaublich mächtig!

Eine solche Umsetzung dient auch als konzeptionelle Grundlage für eine ausgefeiltere Asynchronitätstechnik namens "Communicating Sequential Processes" (CSP), die in Anhang B dieses Buches behandelt wird.

Thunks

Bisher sind wir davon ausgegangen, dass yieldein Promise von einem Generator erhält und dass dieses Promise den Generator über ein Hilfsprogramm wie run(..)fortsetzt - die bestmögliche Art, Asynchronität mit Generatoren zu verwalten. Um es klar zu sagen: Das ist es auch.

Aber wir haben ein anderes Muster übersprungen, das einigermaßen weit verbreitet ist, also werfen wir im Interesse der Vollständigkeit einen kurzen Blick darauf.

In der Informatik gibt es ein altes Konzept aus der Vor-JS-Zeit, dasThunk genannt wird. Ohne sich in der Geschichte zu verzetteln, ist ein Thunk in JS eine Funktion, die - ohne Parameter - eine andere Funktion aufrufen soll.

Mit anderen Worten: Du verpackst eine Funktionsdefinition um einen Funktionsaufruf herum - mit allen Parametern, die sie braucht -, um die Ausführung dieses Aufrufs aufzuschieben, und diese verpackende Funktion ist ein Thunk. Wenn du den Thunk später ausführst, rufst du am Ende die ursprüngliche Funktion auf.

Zum Beispiel:

function foo(x,y) {
    return x + y;
}

function fooThunk() {
    return foo( 3, 4 );
}

// later

console.log( fooThunk() );  // 7

Ein synchroner Thunk ist also ziemlich einfach. Aber was ist mit einem asynchronen Thunk? Wir können die enge Thunk-Definition im Wesentlichen dahingehend erweitern, dass sie einen Callback empfängt.

Bedenke:

function foo(x,y,cb) {
    setTimeout( function(){
        cb( x + y );
    }, 1000 );
}

function fooThunk(cb) {
    foo( 3, 4, cb );
}

// later

fooThunk( function(sum){
    console.log( sum );     // 7
} );

Wie du siehst, erwartet fooThunk(..) nur einen cb(..) Parameter, da er bereits die Werte 3 und 4 (für x bzw. y) vordefiniert hat, die er an foo(..) übergeben kann. Ein Thunk wartet geduldig auf das letzte Teil, das er braucht, um seine Aufgabe zu erfüllen: den Callback.

Du willst die Thunks aber nicht manuell erstellen. Erfinden wir also ein Dienstprogramm, das diese Wraps für uns erledigt.

Bedenke:

function thunkify(fn) {
    var args = [].slice.call( arguments, 1 );
    return function(cb) {
        args.push( cb );
        return fn.apply( null, args );
    };
}

var fooThunk = thunkify( foo, 3, 4 );

// later

fooThunk( function(sum) {
    console.log( sum );     // 7
} );
Tipp

Hier gehen wir davon aus, dass die ursprüngliche (foo(..)) Funktionssignatur ihren Callback an letzter Stelle erwartet, wobei alle anderen Parameter davor stehen. Dies ist ein ziemlich allgegenwärtiger Standard für asynchrone JS-Funktionsstandards. Man könnte es "Callback-Last-Style" nennen. Wenn du aus irgendeinem Grund mit Signaturen im "Callback-First-Stil" umgehen müsstest, würdest du einfach ein Dienstprogramm erstellen, das args.unshift(..) stattargs.push(..) verwendet.

Die vorangegangene Formulierung von thunkify(..) nimmt sowohl die Funktionsreferenz foo(..)als auch alle benötigten Parameter auf und gibt den Thunk selbst zurück (fooThunk(..)). Das ist jedoch nicht der typische Ansatz für Thunks in JS.

Anstatt dass thunkify(..) den Thunk selbst erstellt, erzeugt das Dienstprogramm thunkify(..) typischerweise eine Funktion, die Thunks produziert - auch wenn das nicht verwirrend ist.

Uhhhh...ja.

Bedenke:

function thunkify(fn) {
    return function() {
        var args = [].slice.call( arguments );
        return function(cb) {
            args.push( cb );
            return fn.apply( null, args );
        };
    };
}

Der Hauptunterschied ist die zusätzliche Schicht return function() { .. }. Hier siehst du, wie sich die Verwendung unterscheidet:

var whatIsThis = thunkify( foo );

var fooThunk = whatIsThis( 3, 4 );

// later

fooThunk( function(sum) {
    console.log( sum );     // 7
} );

Die große Frage, die dieses Snippet aufwirft, ist natürlich, wie whatIsThisrichtig heißt. Es ist nicht der Thunk, sondern das Ding, das Thunks aus den Aufrufen von foo(..) erzeugt. Es ist so etwas wie eine "Fabrik" für "Thunks". Es scheint keine Standardvereinbarung für die Benennung eines solchen Dings zu geben.

Mein Vorschlag lautet daher "thunkory" ("thunk" + "factory"). thunkify(..) erzeugt also eine Thunkory, und eine Thunkory erzeugt Thunks. Diese Argumentation ist symmetrisch zu meinem Vorschlag für "promisory" in Kapitel 3:

var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// later

fooThunk1( function(sum) {
    console.log( sum );     // 7
} );

fooThunk2( function(sum) {
    console.log( sum );     // 11
} );
Hinweis

Das laufende foo(..) Beispiel erwartet einen Rückruf, der nicht im "error-first style" erfolgt. Natürlich ist der "error-first style" viel verbreiteter. Wenn foo(..) eine legitime Fehlererwartung hätte, könnten wir es so ändern, dass es einen Fehler-ersten Callback erwartet und verwendet. Keiner der nachfolgenden thunkify(..) Mechanismen kümmert sich darum, welche Art von Rückruf angenommen wird. Der einzige Unterschied in der Verwendung wärefooThunk1(function(err,sum){...

Die Thunkory-Methode offenzulegen - im Gegensatz zuthunkify(..), wo dieser Zwischenschritt versteckt ist - mag als unnötige Komplikation erscheinen. Aber im Allgemeinen ist es sehr nützlich, am Anfang deines Programms Thunkories zu erstellen, um bestehende API-Methoden zu umhüllen, und diese Thunkories dann weiterzugeben und aufzurufen, wenn du Thunks brauchst. Durch die zwei unterschiedlichen Schritte wird eine saubere Trennung der Fähigkeiten beibehalten.

Zur Veranschaulichung:

// cleaner:
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// instead of:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );

Unabhängig davon, ob du dich explizit mit den Thunks beschäftigen möchtest, bleibt die Verwendung der Thunks fooThunk1(..) und fooThunk2(..) gleich.

s/versprechen/thunk/

Was hat dieser ganze Quatsch mit Generatoren zu tun?

Wenn du Thunks mit Versprechen vergleichst: Sie sind nicht direkt austauschbar, da sie sich nicht gleich verhalten. Versprechen sind weitaus leistungsfähiger und vertrauenswürdiger als bloße Thunks.

Aber in einem anderen Sinne können sie beide als eine Anfrage nach einem Wert gesehen werden, die asynchron beantwortet werden kann.

Erinnere dich daran, dass wir in Kapitel 3 ein Dienstprogramm für das Versprechen einer Funktion definiert haben, das wir Promise.wrap(..)nannten - wir hätten es auchpromisify(..) nennen können! Dieses Dienstprogramm zum Einpacken von Versprechen erzeugt keine Versprechen, sondern Promisories, die wiederum Versprechen erzeugen. Das ist völlig symmetrisch zu den Thunkories und Thunks, die wir gerade besprechen.

Um die Symmetrie zu verdeutlichen, ändern wir zunächst das laufende foo(..)Beispiel von vorhin, um einen "error-first style" Callback anzunehmen:

function foo(x,y,cb) {
    setTimeout( function(){
        // assume `cb(..)` as "error-first style"
        cb( null, x + y );
    }, 1000 );
}

Jetzt vergleichen wir mit thunkify(..) und promisify(..) (auch bekannt alsPromise.wrap(..) aus Kapitel 3):

// symmetrical: constructing the question asker
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );

// symmetrical: asking the question
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );

// get the thunk answer
fooThunk( function(err,sum){
    if (err) {
        console.error( err );
    }
    else {
        console.log( sum );     // 7
    }
} );

// get the promise answer
fooPromise
.then(
    function(sum){
        console.log( sum );     // 7
    },
    function(err){
        console.error( err );
    }
);

Sowohl die Thunkory als auch das Promisory stellen im Wesentlichen eine Frage (nach einem Wert), und die Thunkory fooThunk bzw. PromisoryfooPromise stellen die zukünftigen Antworten auf diese Frage dar. So gesehen, ist die Symmetrie klar.

Mit dieser Perspektive im Hinterkopf können wir sehen, dass Generatoren, die yieldPromises für Asynchronität verwenden, stattdessen yield thunks für Asynchronität verwenden könnten. Alles, was wir dazu brauchen, ist ein intelligenteres run(..) Dienstprogramm (wie das vorherige), das nicht nur nach einer yieldPromise suchen und eine Verbindung zu ihr herstellen kann, sondern auch einen Callback für einen yieldThunk bereitstellt.

Bedenke:

function *foo() {
    var val = yield request( "http://some.url.1" );
    console.log( val );
}

run( foo );

In diesem Beispiel könnte request(..) entweder ein Promisory sein, das ein Versprechen zurückgibt, oder ein Thunkory, das einen Thunk zurückgibt. Aus der Perspektive der Logik des Generatorcodes ist uns dieses Implementierungsdetail egal, und das ist ziemlich mächtig!

request(..) könnte also beides sein:

// promisory `request(..)` (see Chapter 3)
var request = Promise.wrap( ajax );

// vs.

// thunkory `request(..)`
var request = thunkify( ajax );

Als Thunk-aware Patch für unser früheres run(..) Dienstprogramm bräuchten wir eine Logik wie diese:

// ..
// did we receive a thunk back?
else if (typeof next.value == "function") {
    return new Promise( function(resolve,reject){
        // call the thunk with an error-first callback
        next.value( function(err,msg) {
            if (err) {
                reject( err );
            }
            else {
                resolve( msg );
            }
        } );
    } )
    .then(
        handleNext,
        function handleErr(err) {
            return Promise.resolve(
                it.throw( err )
            )
            .then( handleResult );
        }
    );
}

Jetzt können unsere Generatoren entweder Promisories aufrufen, um yield Promises zu erzeugen, oder Thunkories aufrufen, um yield Thunks zu erzeugen. In beiden Fällen würde run(..) diesen Wert verarbeiten und ihn verwenden, um auf die Fertigstellung zu warten und den Generator fortzusetzen.

Von der Symmetrie her sehen diese beiden Ansätze identisch aus. Wir sollten jedoch darauf hinweisen, dass dies nur aus der Perspektive von Promises oder Thunks gilt, die die zukünftige Wertfortschreibung eines Generators darstellen.

Im Großen und Ganzen haben Thunks an und für sich keine der Garantien für Vertrauenswürdigkeit oder Zusammensetzbarkeit, mit denen Promises ausgestattet sind. Die Verwendung eines Thunks als Ersatz für ein Promise in diesem speziellen Generator-Asynchronie-Muster ist praktikabel, sollte aber im Vergleich zu den Vorteilen, die Promises bieten, als weniger ideal angesehen werden (siehe Kapitel 3).

Wenn du die Möglichkeit hast, verwende yield pr statt yield th. Es spricht aber nichts dagegen, ein Dienstprogramm run(..) zu haben, das beide Wertetypen verarbeiten kann.

Hinweis

Das Dienstprogramm runner(..) in meiner Asynquence-Bibliothek, das in Anhang A beschrieben wird, behandelt yields von Promises, Thunks undAsynquence-Sequenzen.

Vor-ES6-Generatoren

Du bist jetzt hoffentlich davon überzeugt, dass Generatoren eine sehr wichtige Ergänzung des Werkzeugkastens für die asynchrone Programmierung sind. Aber es handelt sich um eine neue Syntax in ES6, was bedeutet, dass man Generatoren nicht einfach mit Polyfills versehen kann wie Promises (die nur eine neue API sind). Was können wir also tun, um Generatoren in unseren Browser JS zu bringen, wenn wir nicht den Luxus haben, Pre-ES6-Browser zu ignorieren?

Für alle neuen Syntaxerweiterungen in ES6 gibt es Tools - der gebräuchlichste Begriff für sie ist Transpiler, für Trans-Compiler - die deine ES6-Syntax in äquivalenten (aber natürlich hässlicheren!) Pre-ES6-Code umwandeln können. So können Generatoren in Code umgewandelt werden, der sich genauso verhält, aber in ES5 und darunter funktioniert.

Aber wie? Die "Magie" von yield klingt nicht gerade nach Code, der leicht zu übertragen ist. In unserer früheren Diskussion über schließungsbasierte Iteratoren haben wir bereits eine Lösung angedeutet.

Manuelle Umwandlung

Bevor wir uns mit den Transpilern befassen, wollen wir ableiten, wie die manuelle Transpilierung bei Generatoren funktionieren würde. Das ist nicht nur eine akademische Übung, denn dadurch wird die Funktionsweise der Generatoren noch deutlicher.

Bedenke:

// `request(..)` is a Promise-aware Ajax utility

function *foo(url) {
    try {
        console.log( "requesting:", url );
        var val = yield request( url );
        console.log( val );
    }
    catch (err) {
        console.log( "Oops:", err );
        return false;
    }
}

var it = foo( "http://some.url.1" );

Als Erstes müssen wir feststellen, dass wir immer noch eine normale foo()Funktion brauchen, die aufgerufen werden kann und die einenIterator zurückgeben muss. Skizzieren wir also die Umwandlung ohne Generatoren:

function foo(url) {

    // ..

    // make and return an iterator
    return {
        next: function(v) {
            // ..
        },
        throw: function(e) {
            // ..
        }
    };
}

var it = foo( "http://some.url.1" );

Als Nächstes ist zu beachten, dass ein Generator seine "Magie" ausübt, indem er seinen Bereich/Zustand außer Kraft setzt, aber wir können das mit Funktionsschließungen nachahmen (siehe den Titel Scope & Closures in dieser Serie). Um zu verstehen, wie wir einen solchen Code schreiben können, werden wir zunächst verschiedene Teile unseres Generators mit Zustandswerten annotieren:

// `request(..)` is a Promise-aware Ajax utility

function *foo(url) {
    // STATE 1

    try {
        console.log( "requesting:", url );
        var TMP1 = request( url );

        // STATE 2
        var val = yield TMP1;
        console.log( val );
    }
    catch (err) {
        // STATE 3
        console.log( "Oops:", err );
        return false;
    }
}
Hinweis

Zur genaueren Veranschaulichung teilen wir die Anweisungval = yield request.. in zwei Teile auf, indem wir die temporäre VariableTMP1 verwenden. request(..) wird im Zustand 1 ausgeführt, und die Zuweisung des Abschlusswerts an val erfolgt im Zustand 2. Wir werden die Zwischenanweisung TMP1 loswerden, wenn wir den Code in sein Äquivalent ohne Generator umwandeln.

Mit anderen Worten: 1 ist der Anfangszustand, 2 ist der Zustand, wennrequest(..) erfolgreich ist, und 3 ist der Zustand, wenn request(..)fehlschlägt. Du kannst dir wahrscheinlich vorstellen, dass alle zusätzlichen yield Schritte einfach als zusätzliche Zustände kodiert werden würden.

Kehren wir zu unserem transpilierten Generator zurück und definieren wir eine Variable state in der Closure, mit der wir den Status verfolgen können:

function foo(url) {
    // manage generator state
    var state;

    // ..
}

Definieren wir nun eine innere Funktion namens process(..) innerhalb der Closure, die jeden Zustand behandelt, indem wir eine switch Anweisung verwenden:

// `request(..)` is a Promise-aware Ajax utility

function foo(url) {
    // manage generator state
    var state;

    // generator-wide variable declarations
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // ..
}

Jeder Zustand in unserem Generator wird durch eine eigene case in derswitch Anweisung dargestellt. process(..) wird jedes Mal aufgerufen, wenn wir einen neuen Zustand verarbeiten müssen. Wir werden gleich darauf zurückkommen, wie das funktioniert.

Die Deklarationen der generatorweiten Variablen (val) verschieben wir in einevar Deklaration außerhalb von process(..), damit sie mehrere Aufrufe von process(..) überleben können. Die blockspezifische Variable err wird nur für den 3 Zustand benötigt, also lassen wir sie an Ort und Stelle.

Im Zustand 1 haben wir anstelle von yield resolve(..)return resolve(..) gemacht. Im Endzustand 2 gab es kein explizitesreturn, also machen wir einfach ein return;, was dasselbe ist wiereturn undefined. Im Endzustand 3 gab es eine return false, also behalten wir diese bei.

Jetzt müssen wir den Code in den Iteratorfunktionen definieren, damit sieprocess(..) entsprechend aufrufen:

function foo(url) {
    // manage generator state
    var state;

    // generator-wide variable declarations
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // make and return an iterator
    return {
        next: function(v) {
            // initial state
            if (!state) {
                state = 1;
                return {
                    done: false,
                    value: process()
                };
            }
            // yield resumed successfully
            else if (state == 1) {
                state = 2;
                return {
                    done: true,
                    value: process( v )
                };
            }
            // generator already completed
            else {
                return {
                    done: true,
                    value: undefined
                };
            }
        },
        "throw": function(e) {
            // the only explicit error handling is in
            // state 1
            if (state == 1) {
                state = 3;
                return {
                    done: true,
                    value: process( e )
                };
            }
            // otherwise, an error won't be handled,
            // so just throw it right back out
            else {
                throw e;
            }
        }
    };
}

Wie funktioniert dieser Code?

  1. Der erste Aufruf des Iterators next() würde den Generator vom unitialisierten Zustand in den Zustand 1 versetzen und dannprocess() aufrufen, um diesen Zustand zu behandeln. Der Rückgabewert von request(..), der das Versprechen für die Ajax-Antwort ist, wird als Eigenschaftvalue vom Aufruf next() zurückgegeben.

  2. Wenn die Ajax-Anfrage erfolgreich war, sollte der zweite Aufruf von next(..) den Ajax-Antwortwert übermitteln, der unseren Status nach 2 verschiebt.process(..) wird erneut aufgerufen (diesmal mit dem übergebenen Ajax-Antwortwert), und die von next(..) zurückgegebene Eigenschaft value wird undefined sein.

  3. Wenn die Ajax-Anfrage jedoch fehlschlägt, sollte throw(..) mit dem Fehler aufgerufen werden, wodurch der Status von 1 nach 3 (statt nach2) verschoben würde. Erneut wird process(..) aufgerufen, dieses Mal mit dem Fehlerwert. case gibt false zurück, das als Eigenschaft value gesetzt wird, die vom Aufruf throw(..) zurückgegeben wird.

Von außen betrachtet, d.h. wenn du nur mit dem Iteratorinteragierst ,funktioniert diesenormale Funktion von foo(..) so ziemlich genauso wie der Generator von*foo(..). Wir haben also unseren ES6-Generator effektiv auf die Zeit vor ES6 zurückgeführt!

Wir könnten dann unseren Generator manuell instanziieren und seinen Iterator steuern - indem wir var it = foo("..") und it.next(..) und so weiter aufrufen - oder noch besser, wir könnten ihn an unser zuvor definiertes Dienstprogramm run(..) als run(foo,"..") übergeben.

Automatische Transpilation

Die vorangegangene Übung, bei der wir unseren ES6-Generator manuell in das Prä-ES6-Äquivalent umgewandelt haben, hat uns gezeigt, wie Generatoren konzeptionell funktionieren. Aber diese Umwandlung war sehr kompliziert und nicht auf andere Generatoren in unserem Code übertragbar. Es wäre ziemlich unpraktisch, diese Arbeit von Hand zu machen, und würde den Nutzen der Generatoren komplett zunichte machen.

Aber zum Glück gibt es bereits einige Tools, die ES6-Generatoren automatisch in das umwandeln können, was wir im vorherigen Abschnitt abgeleitet haben. Sie nehmen uns nicht nur die schwere Arbeit ab, sondern kümmern sich auch um einige Komplikationen, die wir übergangen haben.

Ein solches Tool ist Regenerator von den cleveren Leuten bei Facebook.

Wenn wir den Regenerator verwenden, um unseren vorherigen Generator zu übersetzen, entsteht folgender Code (zum Zeitpunkt der Erstellung dieses Artikels):

// `request(..)` is a Promise-aware Ajax utility

var foo = regeneratorRuntime.mark(function foo(url) {
    var val;

    return regeneratorRuntime.wrap(function foo$(context$1$0) {
        while (1) switch (context$1$0.prev = context$1$0.next) {
        case 0:
            context$1$0.prev = 0;
            console.log( "requesting:", url );
            context$1$0.next = 4;
            return request( url );
        case 4:
            val = context$1$0.sent;
            console.log( val );
            context$1$0.next = 12;
            break;
        case 8:
            context$1$0.prev = 8;
            context$1$0.t0 = context$1$0.catch(0);
            console.log("Oops:", context$1$0.t0);
            return context$1$0.abrupt("return", false);
        case 12:
        case "end":
            return context$1$0.stop();
        }
    }, foo, this, [[0, 8]]);
});

Es gibt hier einige offensichtliche Ähnlichkeiten zu unserer manuellen Ableitung, wie z.B. die switch / case Anweisungen, und wir sehen sogar, dass val aus der Schließung herausgezogen wurde, genau wie wir es getan haben.

Ein Kompromiss besteht natürlich darin, dass die Transpilierung des Regenerators eine Hilfsbibliothek regeneratorRuntime erfordert, die die gesamte wiederverwendbare Logik für die Verwaltung eines allgemeinen Generators/Iterators enthält. Vieles in dieser Bibliothek sieht anders aus als in unserer Version, aber auch hier sind die Konzepte erkennbar, z. B. context$1$0.next = 4, das den nächsten Zustand des Generators festhält.

Die wichtigste Erkenntnis ist, dass Generatoren nicht nur in ES6+ Umgebungen nützlich sind. Sobald du die Konzepte verstanden hast, kannst du sie in deinem gesamten Code einsetzen und Tools verwenden, um den Code für ältere Umgebungen kompatibel zu machen.

Das ist zwar mehr Arbeit als ein Promise API Polyfill für pre-ES6 Promises, aber der Aufwand lohnt sich, weil Generatoren die asynchrone Ablaufsteuerung viel besser in einer vernünftigen, vernünftigen, synchron aussehenden, sequenziellen Weise ausdrücken können.

Wenn du einmal von den Generatoren begeistert bist, wirst du nie wieder in die Hölle der asynchronen Spaghetti-Callbacks zurückkehren wollen!

Überprüfung

Generatoren sind ein neuer ES6-Funktionstyp, der nicht wie normale Funktionen bis zur Fertigstellung läuft. Stattdessen kann der Generator mitten in der Ausführung angehalten werden (wobei sein Zustand vollständig erhalten bleibt) und kann später an der Stelle fortgesetzt werden, an der er aufgehört hat.

Dieser Austausch zwischen Pause und Wiederaufnahme ist kooperativ und nicht präventiv, d.h. der Generator hat die alleinige Möglichkeit, sich selbst mit dem Schlüsselwort yield anzuhalten, und der Iterator, der den Generator steuert, hat die alleinige Möglichkeit (über next(..)), den Generator wieder aufzunehmen.

Die yield / next(..) Dualität ist nicht nur ein Kontrollmechanismus, sondern auch ein Mechanismus zur Weitergabe von Nachrichten in beide Richtungen. Ein yield .. -Ausdruck wartet auf einen Wert und der nächste next(..) -Aufruf gibt einen Wert (oder eine implizite undefined) an den angehaltenen yield-Ausdruck zurück.

Der Hauptvorteil von Generatoren in Bezug auf die asynchrone Ablaufsteuerung besteht darin, dass der Code innerhalb eines Generators eine Abfolge von Schritten für die Aufgabe in einer natürlich synchronen/sequenziellen Weise ausdrückt. Der Trick besteht darin, dass wir potenzielle Asynchronität hinter dem Schlüsselwort yield verstecken und die Asynchronität in den Code verlagern, in dem der Iterator des Generators gesteuert wird.

Mit anderen Worten: Generatoren bewahren ein sequenzielles, synchrones, blockierendes Codemuster für asynchronen Code, wodurch unser Gehirn viel natürlicher über den Code nachdenken kann und einer der beiden Hauptnachteile von callback-basiertem asynchronem Code behoben wird.

Get Du kennst JS nicht: Asynchronität und Leistung now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.