.then().resolve().then()-Aufrufen.catch().then()-Callbacks zurückgegeben werden Erforderliches Wissen: Promises
Für dieses Kapitel sollten Sie ungefähr mit Promises vertraut sein, aber viel relevantes Wissen wird hier auch wiederholt. Wenn nötig, können Sie das Kapitel über Promises in „JavaScript for impatient programmers“ lesen.
In diesem Kapitel nähern wir uns Promises aus einem anderen Blickwinkel: Anstatt diese API zu verwenden, erstellen wir eine einfache Implementierung davon. Dieser andere Blickwinkel hat mir einmal sehr geholfen, Promises zu verstehen.
Die Promise-Implementierung ist die Klasse ToyPromise. Um leichter verständlich zu sein, entspricht sie nicht vollständig der API. Sie ist aber nah genug, um uns immer noch viele Einblicke in die Funktionsweise von Promises zu geben.
Repository mit Code
ToyPromise ist auf GitHub im Repository toy-promise verfügbar.
Wir beginnen mit einer vereinfachten Version, wie Promise-Zustände funktionieren (Abb. 11)
v aufgelöst wird, wird es erfüllt (später sehen wir, dass Auflösen auch Ablehnen kann). v ist nun der Erfüllungswert des Promises.e abgelehnt wird, wird es zurückgewiesen (rejected). e ist nun der Ablehnungsgrund des Promises.Unsere erste Implementierung ist ein eigenständiges Promise mit minimaler Funktionalität
.then() registrieren. Die Registrierung muss das Richtige tun, unabhängig davon, ob das Promise bereits entschieden wurde oder nicht..then() unterstützt noch keine Verkettung – es gibt nichts zurück.ToyPromise1 ist eine Klasse mit drei Prototyp-Methoden
ToyPromise1.prototype.resolve(value)ToyPromise1.prototype.reject(reason)ToyPromise1.prototype.then(onFulfilled, onRejected)Das heißt, resolve und reject sind Methoden (und keine Funktionen, die einem Callback-Parameter des Konstruktors übergeben werden).
So wird diese erste Implementierung verwendet
// .resolve() before .then()
const tp1 = new ToyPromise1();
tp1.resolve('abc');
tp1.then((value) => {
assert.equal(value, 'abc');
});// .then() before .resolve()
const tp2 = new ToyPromise1();
tp2.then((value) => {
assert.equal(value, 'def');
});
tp2.resolve('def');Abb. 12 illustriert, wie unser erstes ToyPromise funktioniert.
Die Diagramme des Datenflusses in Promises sind optional
Die Motivation für die Diagramme ist, eine visuelle Erklärung dafür zu haben, wie Promises funktionieren. Sie sind jedoch optional. Wenn Sie sie verwirrend finden, können Sie sie ignorieren und sich auf den Code konzentrieren.
ToyPromise1: Wenn ein Promise aufgelöst wird, wird der bereitgestellte Wert an die Erfüllungsreaktionen (erste Argumente von .then()) weitergegeben. Wenn ein Promise abgelehnt wird, wird der bereitgestellte Wert an die Ablehnungsreaktionen (zweite Argumente von .then()) weitergegeben..then()Betrachten wir zuerst .then(). Es muss zwei Fälle behandeln
onFulfilled und onRejected ein. Diese sollen später verwendet werden, wenn das Promise entschieden ist.onFulfilled oder onRejected sofort aufgerufen werden.then(onFulfilled, onRejected) {
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
onFulfilled(this._promiseResult);
}
};
const rejectionTask = () => {
if (typeof onRejected === 'function') {
onRejected(this._promiseResult);
}
};
switch (this._promiseState) {
case 'pending':
this._fulfillmentTasks.push(fulfillmentTask);
this._rejectionTasks.push(rejectionTask);
break;
case 'fulfilled':
addToTaskQueue(fulfillmentTask);
break;
case 'rejected':
addToTaskQueue(rejectionTask);
break;
default:
throw new Error();
}
}Der vorherige Code-Schnipsel verwendet die folgende Hilfsfunktion
Promises müssen sich immer asynchron entscheiden. Deshalb führen wir Aufgaben nicht direkt aus, sondern fügen sie der Aufgabenwarteschlange der Event-Schleife hinzu (von Browsern, Node.js usw.). Beachten Sie, dass die tatsächliche Promise-API keine normalen Aufgaben (wie setTimeout()) verwendet, sondern Microtasks, die eng mit der aktuellen normalen Aufgabe verknüpft sind und immer direkt danach ausgeführt werden.
.resolve().resolve() funktioniert wie folgt: Wenn das Promise bereits entschieden ist, tut es nichts (sicherstellt, dass ein Promise nur einmal entschieden werden kann). Andernfalls wechselt der Zustand des Promises zu 'fulfilled' und das Ergebnis wird in this.promiseResult zwischengespeichert. Als Nächstes werden alle bisher eingereihten Erfüllungsreaktionen aufgerufen.
resolve(value) {
if (this._promiseState !== 'pending') return this;
this._promiseState = 'fulfilled';
this._promiseResult = value;
this._clearAndEnqueueTasks(this._fulfillmentTasks);
return this; // enable chaining
}_clearAndEnqueueTasks(tasks) {
this._fulfillmentTasks = undefined;
this._rejectionTasks = undefined;
tasks.map(addToTaskQueue);
}reject() ist ähnlich wie resolve().
.then()-AufrufenToyPromise2 verkettet .then()-Aufrufe: .then() gibt nun ein Promise zurück, das mit jedem Wert aufgelöst wird, der von der Erfüllungsreaktion oder der Ablehnungsreaktion zurückgegeben wird.Das nächste Feature, das wir implementieren, ist die Verkettung (Abb. 13): Ein Wert, den wir aus einer Erfüllungs- oder Ablehnungsreaktion zurückgeben, kann von einer Erfüllungsreaktion in einem folgenden .then()-Aufruf verarbeitet werden. (In der nächsten Version wird die Verkettung dank spezieller Unterstützung für die Rückgabe von Promises wesentlich nützlicher.)
Im folgenden Beispiel
.then(): Wir geben einen Wert in einer Erfüllungsreaktion zurück..then(): Wir empfangen diesen Wert über eine Erfüllungsreaktion.new ToyPromise2()
.resolve('result1')
.then(x => {
assert.equal(x, 'result1');
return 'result2';
})
.then(x => {
assert.equal(x, 'result2');
});Im folgenden Beispiel
.then(): Wir geben einen Wert in einer Ablehnungsreaktion zurück..then(): Wir empfangen diesen Wert über eine Erfüllungsreaktion.new ToyPromise2()
.reject('error1')
.then(null,
x => {
assert.equal(x, 'error1');
return 'result2';
})
.then(x => {
assert.equal(x, 'result2');
});.catch()Die neue Version führt eine Komfortmethode .catch() ein, die es einfacher macht, nur eine Ablehnungsreaktion anzugeben. Beachten Sie, dass es bereits einfach ist, nur eine Erfüllungsreaktion anzugeben – wir lassen einfach den zweiten Parameter von .then() weg (siehe vorheriges Beispiel).
Das vorherige Beispiel sieht besser aus, wenn wir es verwenden (Zeile A)
new ToyPromise2()
.reject('error1')
.catch(x => { // (A)
assert.equal(x, 'error1');
return 'result2';
})
.then(x => {
assert.equal(x, 'result2');
});Die folgenden beiden Methodenaufrufe sind äquivalent
So wird .catch() implementiert
Die neue Version leitet auch Erfüllungen weiter, wenn wir eine Erfüllungsreaktion weglassen, und sie leitet Ablehnungen weiter, wenn wir eine Ablehnungsreaktion weglassen. Warum ist das nützlich?
Das folgende Beispiel demonstriert die Weiterleitung von Ablehnungen
someAsyncFunction()
.then(fulfillmentReaction1)
.then(fulfillmentReaction2)
.catch(rejectionReaction);rejectionReaction kann nun die Ablehnungen von someAsyncFunction(), fulfillmentReaction1 und fulfillmentReaction2 verarbeiten.
Das folgende Beispiel demonstriert die Weiterleitung von Erfüllungen
Wenn someAsyncFunction() sein Promise ablehnt, kann rejectionReaction alles beheben, was falsch ist, und einen Erfüllungswert zurückgeben, der dann von fulfillmentReaction verarbeitet wird.
Wenn someAsyncFunction() sein Promise erfüllt, kann fulfillmentReaction es auch verarbeiten, da .catch() übersprungen wird.
Wie wird all das unter der Haube gehandhabt?
.then() gibt ein Promise zurück, das mit dem aufgelöst wird, was entweder onFulfilled oder onRejected zurückgibt.onFulfilled oder onRejected fehlen, wird das, was sie empfangen hätten, an das von .then() zurückgegebene Promise weitergegeben.Nur .then() ändert sich
then(onFulfilled, onRejected) {
const resultPromise = new ToyPromise2(); // [new]
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
const returned = onFulfilled(this._promiseResult);
resultPromise.resolve(returned); // [new]
} else { // [new]
// `onFulfilled` is missing
// => we must pass on the fulfillment value
resultPromise.resolve(this._promiseResult);
}
};
const rejectionTask = () => {
if (typeof onRejected === 'function') {
const returned = onRejected(this._promiseResult);
resultPromise.resolve(returned); // [new]
} else { // [new]
// `onRejected` is missing
// => we must pass on the rejection value
resultPromise.reject(this._promiseResult);
}
};
···
return resultPromise; // [new]
}.then() erstellt und gibt ein neues Promise zurück (erste und letzte Zeile der Methode). Zusätzlich
fulfillmentTask funktioniert anders. Das passiert jetzt nach der ErfüllungonFulfilled bereitgestellt wurde, wird es aufgerufen und sein Ergebnis wird verwendet, um resultPromise aufzulösen.onFulfilled fehlt, verwenden wir den Erfüllungswert des aktuellen Promises, um resultPromise aufzulösen.rejectionTask funktioniert anders. Das passiert jetzt nach der AblehnungonRejected bereitgestellt wurde, wird es aufgerufen und sein Ergebnis wird verwendet, um resultPromise aufzulösen. Beachten Sie, dass resultPromise nicht abgelehnt wird: Wir gehen davon aus, dass onRejected() jedes Problem behoben hat.onRejected fehlt, verwenden wir den Ablehnungsgrund des aktuellen Promises, um resultPromise abzulehnen..then()-Callbacks zurückgegeben werden.then()Promise-Abflachung dient hauptsächlich dazu, die Verkettung bequemer zu machen: Wenn wir einen Wert von einem .then()-Callback zum nächsten weitergeben möchten, geben wir ihn im ersteren zurück. Danach steckt .then() ihn in das Promise, das es bereits zurückgegeben hat.
Dieser Ansatz wird unbequem, wenn wir ein Promise aus einem .then()-Callback zurückgeben. Zum Beispiel das Ergebnis einer Promise-basierten Funktion (Zeile A)
asyncFunc1()
.then((result1) => {
assert.equal(result1, 'Result of asyncFunc1()');
return asyncFunc2(); // (A)
})
.then((result2Promise) => {
result2Promise
.then((result2) => { // (B)
assert.equal(
result2, 'Result of asyncFunc2()');
});
});Diesmal zwingt uns das Platzieren des in Zeile A zurückgegebenen Wertes in das von .then() zurückgegebene Promise, dieses Promise in Zeile B zu entpacken. Es wäre schön, wenn stattdessen das in Zeile A zurückgegebene Promise das von .then() zurückgegebene Promise ersetzen würde. Wie genau das geschehen könnte, ist nicht sofort klar, aber wenn es funktionieren würde, könnten wir unseren Code so schreiben
asyncFunc1()
.then((result1) => {
assert.equal(result1, 'Result of asyncFunc1()');
return asyncFunc2(); // (A)
})
.then((result2) => {
// result2 is the fulfillment value, not the Promise
assert.equal(
result2, 'Result of asyncFunc2()');
});In Zeile A gaben wir ein Promise zurück. Dank Promise-Abflachung ist result2 der Erfüllungswert dieses Promises, nicht das Promise selbst.
Abflachen von Promises in der ECMAScript-Spezifikation
In der ECMAScript-Spezifikation sind die Details des Abflachens von Promises in Abschnitt „Promise Objects“ beschrieben.
Wie geht die Promise-API mit dem Abflachen um?
Wenn ein Promise P mit einem Promise Q aufgelöst wird, verpackt P Q nicht, P „wird“ Q: Zustand und Entscheidungswert von P sind nun immer gleich wie die von Q. Das hilft uns bei .then(), da .then() das Promise, das es zurückgibt, mit dem Wert auflöst, der von einem seiner Callbacks zurückgegeben wird.
Wie wird P zu Q? Durch Sperren auf Q: P wird extern unentscheidbar und eine Entscheidung von Q löst eine Entscheidung von P aus. Lock-in ist ein zusätzlicher unsichtbarer Promise-Zustand, der die Zustände komplizierter macht.
Die Promise-API hat eine zusätzliche Funktion: Q muss kein Promise sein, sondern nur ein sogenannter thenable. Ein Thenable ist ein Objekt mit einer Methode .then(). Der Grund für diese zusätzliche Flexibilität ist, die Zusammenarbeit verschiedener Promise-Implementierungen zu ermöglichen (was wichtig war, als Promises zuerst zur Sprache hinzugefügt wurden).
Abb. 14 visualisiert die neuen Zustände.
Beachten Sie, dass das Konzept des Auflösens ebenfalls komplizierter geworden ist. Das Auflösen eines Promises bedeutet nun nur noch, dass es nicht mehr direkt entschieden werden kann.
Die ECMAScript-Spezifikation formuliert es so: „Ein unentschiedenes Promise befindet sich immer im ausstehenden Zustand. Ein entschiedenes Promise kann ausstehend, erfüllt oder abgelehnt sein.“
Abb. 15 zeigt, wie ToyPromise3 das Abflachen behandelt.
ToyPromise3 flacht aufgelöste Promises ab: Wenn das erste Promise mit einem Thenable x1 aufgelöst wird, sperrt es sich auf x1 und wird mit dem Entscheidungswert von x1 entschieden. Wenn das erste Promise mit einem Nicht-Thenable-Wert aufgelöst wird, funktioniert alles wie zuvor.Wir erkennen Thenables anhand dieser Funktion
function isThenable(value) { // [new]
return typeof value === 'object' && value !== null
&& typeof value.then === 'function';
}Um den Lock-in zu implementieren, führen wir ein neues boolesches Flag ._alreadyResolved ein. Das Setzen auf true deaktiviert .resolve() und .reject() – zum Beispiel
resolve(value) { // [new]
if (this._alreadyResolved) return this;
this._alreadyResolved = true;
if (isThenable(value)) {
// Forward fulfillments and rejections from `value` to `this`.
// The callbacks are always executed asynchronously
value.then(
(result) => this._doFulfill(result),
(error) => this._doReject(error));
} else {
this._doFulfill(value);
}
return this; // enable chaining
}Wenn value ein Thenable ist, sperren wir das aktuelle Promise darauf
value mit einem Ergebnis erfüllt wird, wird das aktuelle Promise ebenfalls mit diesem Ergebnis erfüllt.value mit einem Fehler abgelehnt wird, wird das aktuelle Promise ebenfalls mit diesem Fehler abgelehnt.Die Entscheidung wird über die privaten Methoden ._doFulfill() und ._doReject() durchgeführt, um den Schutz durch ._alreadyResolved zu umgehen.
._doFulfill() ist relativ einfach
_doFulfill(value) { // [new]
assert.ok(!isThenable(value));
this._promiseState = 'fulfilled';
this._promiseResult = value;
this._clearAndEnqueueTasks(this._fulfillmentTasks);
}.reject() wird hier nicht gezeigt. Seine einzige neue Funktionalität ist, dass es nun ebenfalls ._alreadyResolved beachtet.
ToyPromise4 wandelt Ausnahmen in Promise-Reaktionen in Ablehnungen des von .then() zurückgegebenen Promises um.Als letztes Feature möchten wir, dass unsere Promises Ausnahmen im Benutzercode als Ablehnungen behandeln (Abb. 16). In diesem Kapitel bedeutet „Benutzercode“ die beiden Callback-Parameter von .then().
new ToyPromise4()
.resolve('a')
.then((value) => {
assert.equal(value, 'a');
throw 'b'; // triggers a rejection
})
.catch((error) => {
assert.equal(error, 'b');
}).then() führt nun die Promise-Reaktionen onFulfilled und onRejected sicher über die Hilfsmethode ._runReactionSafely() aus – zum Beispiel
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
this._runReactionSafely(resultPromise, onFulfilled); // [new]
} else {
// `onFulfilled` is missing
// => we must pass on the fulfillment value
resultPromise.resolve(this._promiseResult);
}
};._runReactionSafely() wird wie folgt implementiert
_runReactionSafely(resultPromise, reaction) { // [new]
try {
const returned = reaction(this._promiseResult);
resultPromise.resolve(returned);
} catch (e) {
resultPromise.reject(e);
}
}Wir überspringen einen letzten Schritt: Wenn wir ToyPromise in eine tatsächliche Promise-Implementierung verwandeln wollten, müssten wir noch das Revealing Constructor Pattern implementieren: JavaScript Promises werden nicht über Methoden aufgelöst und abgelehnt, sondern über Funktionen, die dem Executor, dem Callback-Parameter des Konstruktors, übergeben werden.
Wenn der Executor eine Ausnahme auslöst, wird promise abgelehnt.