Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

17 Erforschung von Promises durch ihre Implementierung



  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.

17.1 Auffrischung: Zustände von Promises

Abbildung 11: Die Zustände eines Promises (vereinfachte Version): Ein Promise ist anfangs ausstehend. Wenn wir es auflösen, wird es erfüllt. Wenn wir es ablehnen, wird es zurückgewiesen.

Wir beginnen mit einer vereinfachten Version, wie Promise-Zustände funktionieren (Abb. 11)

17.2 Version 1: Eigenständiges Promise

Unsere erste Implementierung ist ein eigenständiges Promise mit minimaler Funktionalität

ToyPromise1 ist eine Klasse mit drei Prototyp-Methoden

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.

Abbildung 12: 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.

17.2.1 Methode .then()

Betrachten wir zuerst .then(). Es muss zwei Fälle behandeln

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

function addToTaskQueue(task) {
  setTimeout(task, 0);
}

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.

17.2.2 Methode .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().

17.3 Version 2: Verketten von .then()-Aufrufen

Abbildung 13: ToyPromise2 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

new ToyPromise2()
  .resolve('result1')
  .then(x => {
    assert.equal(x, 'result1');
    return 'result2';
  })
  .then(x => {
    assert.equal(x, 'result2');
  });

Im folgenden Beispiel

new ToyPromise2()
  .reject('error1')
  .then(null,
    x => {
      assert.equal(x, 'error1');
      return 'result2';
    })
  .then(x => {
    assert.equal(x, 'result2');
  });

17.4 Komfortmethode .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

.catch(rejectionReaction)
.then(null, rejectionReaction)

So wird .catch() implementiert

catch(onRejected) { // [new]
  return this.then(null, onRejected);
}

17.5 Weglassen von Reaktionen

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

someAsyncFunction()
  .catch(rejectionReaction)
  .then(fulfillmentReaction);

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.

17.6 Die Implementierung

Wie wird all das unter der Haube gehandhabt?

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

17.7 Version 3: Abflachen von Promises, die von .then()-Callbacks zurückgegeben werden

17.7.1 Zurückgeben von Promises aus einem Callback von .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.

17.7.2 Abflachen macht Promise-Zustände komplizierter

  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.

Abbildung 14: Alle Zustände eines Promises: Promise-Abflachung führt den unsichtbaren Pseudo-Zustand „locked-in“ ein. Dieser Zustand wird erreicht, wenn ein Promise P mit einem Thenable Q aufgelöst wird. Danach sind Zustand und Entscheidungswert von P immer gleich wie die von Q.

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.“

17.7.3 Implementierung des Promise-Abflachens

Abb. 15 zeigt, wie ToyPromise3 das Abflachen behandelt.

Abbildung 15: 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

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.

17.8 Version 4: Ausnahmen, die in Reaktions-Callbacks ausgelöst werden

Abbildung 16: 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);
  }
}

17.9 Version 5: Revealing Constructor Pattern

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.

const promise = new Promise(
  (resolve, reject) => { // executor
    // ···
  });

Wenn der Executor eine Ausnahme auslöst, wird promise abgelehnt.