JavaScript für ungeduldige Programmierer (ES2022-Ausgabe)
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

39 Asynchrone Programmierung in JavaScript



Dieses Kapitel erklärt die Grundlagen der asynchronen Programmierung in JavaScript.

39.1 Eine Roadmap für asynchrone Programmierung in JavaScript

Dieser Abschnitt bietet eine Roadmap für die Inhalte zur asynchronen Programmierung in JavaScript.

  Machen Sie sich keine Sorgen um die Details!

Machen Sie sich keine Sorgen, wenn Sie noch nicht alles verstehen. Dies ist nur ein kurzer Blick auf das, was kommt.

39.1.1 Synchrone Funktionen

Normale Funktionen sind synchron: Der Aufrufer wartet, bis der Aufgerufene seine Berechnung abgeschlossen hat. divideSync() in Zeile A ist ein synchroner Funktionsaufruf

function main() {
  try {
    const result = divideSync(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

39.1.2 JavaScript führt Aufgaben sequenziell in einem einzigen Prozess aus

Standardmäßig sind JavaScript-Aufgaben Funktionen, die sequenziell in einem einzigen Prozess ausgeführt werden. Das sieht so aus

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

Diese Schleife wird auch Event-Schleife genannt, da Events, wie z. B. ein Mausklick, Aufgaben zur Warteschlange hinzufügen.

Aufgrund dieses kooperativen Multitasking-Stils möchten wir nicht, dass eine Aufgabe andere Aufgaben an der Ausführung hindert, während sie beispielsweise auf Ergebnisse von einem Server wartet. Der nächste Unterabschnitt untersucht, wie dieser Fall behandelt wird.

39.1.3 Callback-basierte asynchrone Funktionen

Was, wenn divide() einen Server benötigt, um sein Ergebnis zu berechnen? Dann sollte das Ergebnis auf eine andere Weise geliefert werden: Der Aufrufer sollte nicht (synchron) warten müssen, bis das Ergebnis fertig ist; er sollte benachrichtigt werden (asynchron), wenn es fertig ist. Eine Möglichkeit, das Ergebnis asynchron zu liefern, besteht darin, divide() eine Callback-Funktion zu übergeben, mit der es den Aufrufer benachrichtigt.

function main() {
  divideCallback(12, 3,
    (err, result) => {
      if (err) {
        assert.fail(err);
      } else {
        assert.equal(result, 4);
      }
    });
}

Wenn ein asynchroner Funktionsaufruf stattfindet

divideCallback(x, y, callback)

Dann geschehen die folgenden Schritte

39.1.4 Promise-basierte asynchrone Funktionen

Promises sind zwei Dinge

Der Aufruf einer Promise-basierten Funktion sieht folgendermaßen aus.

function main() {
  dividePromise(12, 3)
    .then(result => assert.equal(result, 4))
    .catch(err => assert.fail(err));
}

39.1.5 Async-Funktionen

Eine Möglichkeit, Async-Funktionen zu betrachten, ist als bessere Syntax für Promise-basierten Code

async function main() {
  try {
    const result = await dividePromise(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

Die dividePromise(), die wir in Zeile A aufrufen, ist dieselbe Promise-basierte Funktion wie im vorherigen Abschnitt. Aber wir haben jetzt eine synchron erscheinende Syntax für die Behandlung des Aufrufs. await kann nur innerhalb einer speziellen Art von Funktion verwendet werden, einer Async-Funktion (beachten Sie das Schlüsselwort async vor dem Schlüsselwort function). await pausiert die aktuelle Async-Funktion und kehrt von ihr zurück. Sobald das erwartete Ergebnis fertig ist, wird die Ausführung der Funktion dort fortgesetzt, wo sie aufgehört hat.

39.1.6 Nächste Schritte

39.2 Der Call Stack

Immer wenn eine Funktion eine andere Funktion aufruft, müssen wir uns merken, wohin wir nach Abschluss der letzteren Funktion zurückkehren müssen. Das geschieht typischerweise über einen Stack – den Call Stack: Der Aufrufer legt die Rücksprungadresse darauf, und der Aufgerufene springt nach seiner Fertigstellung zu dieser Adresse.

Dies ist ein Beispiel, bei dem mehrere Aufrufe stattfinden

function h(z) {
  const error = new Error();
  console.log(error.stack);
}
function g(y) {
  h(y + 1);
}
function f(x) {
  g(x + 1);
}
f(3);
// done

Anfangs, bevor dieser Codefragment ausgeführt wird, ist der Call Stack leer. Nach dem Funktionsaufruf f(3) in Zeile 11 hat der Stack einen Eintrag

Nach dem Funktionsaufruf g(x + 1) in Zeile 9 hat der Stack zwei Einträge

Nach dem Funktionsaufruf h(y + 1) in Zeile 6 hat der Stack drei Einträge

Das Protokollieren von error in Zeile 3 erzeugt die folgende Ausgabe

DEBUG
Error: 
    at h (file://demos/async-js/stack_trace.mjs:2:17)
    at g (file://demos/async-js/stack_trace.mjs:6:3)
    at f (file://demos/async-js/stack_trace.mjs:9:3)
    at file://demos/async-js/stack_trace.mjs:11:1

Dies ist ein sogenannter Stack Trace, der angibt, wo das Error-Objekt erstellt wurde. Beachten Sie, dass er aufzeichnet, wo Aufrufe gemacht wurden, nicht Rücksprungadressen. Das Erstellen der Ausnahme in Zeile 2 ist ein weiterer Aufruf. Deshalb enthält der Stack Trace einen Ort innerhalb von h().

Nach Zeile 3 terminiert jede der Funktionen und jedes Mal wird der oberste Eintrag aus dem Call Stack entfernt. Nachdem die Funktion f fertig ist, sind wir zurück im Top-Level-Scope und der Stack ist leer. Wenn das Codefragment endet, ist das wie ein implizites return. Wenn wir das Codefragment als eine Aufgabe betrachten, die ausgeführt wird, dann beendet die Rückkehr mit einem leeren Call Stack die Aufgabe.

39.3 Die Event-Schleife

Standardmäßig läuft JavaScript in einem einzigen Prozess – sowohl in Webbrowsern als auch in Node.js. Die sogenannte Event-Schleife führt Aufgaben (Code-Teile) sequenziell innerhalb dieses Prozesses aus. Die Event-Schleife ist in Abb. 21 dargestellt.

Figure 21: Task sources add code to run to the task queue, which is emptied by the event loop.

Zwei Parteien greifen auf die Aufgabenwarteschlange zu

Der folgende JavaScript-Code ist eine Annäherung an die Event-Schleife

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

39.4 Wie man den JavaScript-Prozess nicht blockiert

39.4.1 Die Benutzeroberfläche des Browsers kann blockiert werden

Viele der Benutzeroberflächenmechanismen von Browsern laufen ebenfalls im JavaScript-Prozess (als Aufgaben). Daher kann langlaufender JavaScript-Code die Benutzeroberfläche blockieren. Betrachten wir eine Webseite, die das demonstriert. Es gibt zwei Möglichkeiten, diese Seite auszuprobieren

Das folgende HTML ist die Benutzeroberfläche der Seite

<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>

Die Idee ist, dass Sie auf "Blockieren" klicken und eine langlaufende Schleife über JavaScript ausgeführt wird. Während dieser Schleife können Sie nicht auf den Button klicken, da der Browser/JavaScript-Prozess blockiert ist.

Eine vereinfachte Version des JavaScript-Codes sieht so aus

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)

function doBlock(event) {
  // ···
  displayStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  displayStatus('Done');
}

function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

Dies sind die wichtigsten Teile des Codes

39.4.2 Wie können wir den Browser nicht blockieren?

Es gibt verschiedene Möglichkeiten, eine langlaufende Operation daran zu hindern, den Browser zu blockieren

39.4.3 Pausen machen

Die folgende globale Funktion führt ihren Parameter callback nach einer Verzögerung von ms Millisekunden aus (die Signatur ist vereinfacht – setTimeout() hat mehr Funktionen)

function setTimeout(callback: () => void, ms: number): any

Die Funktion gibt ein Handle (eine ID) zurück, das verwendet werden kann, um den Timeout zu löschen (die Ausführung des Callbacks abzubrechen) über die folgende globale Funktion

function clearTimeout(handle?: any): void

setTimeout() ist sowohl in Browsern als auch in Node.js verfügbar. Der nächste Unterabschnitt zeigt sie in Aktion.

  setTimeout() lässt Aufgaben Pausen machen

Eine andere Sichtweise auf setTimeout() ist, dass die aktuelle Aufgabe eine Pause macht und später über den Callback fortfährt.

39.4.4 Run-to-completion-Semantik

JavaScript bietet eine Garantie für Aufgaben

Jede Aufgabe wird immer abgeschlossen ("run to completion"), bevor die nächste Aufgabe ausgeführt wird.

Infolgedessen müssen sich Aufgaben keine Sorgen machen, dass ihre Daten geändert werden, während sie daran arbeiten (gleichzeitige Änderung). Das vereinfacht die Programmierung in JavaScript.

Das folgende Beispiel demonstriert diese Garantie

console.log('start');
setTimeout(() => {
  console.log('callback');
}, 0);
console.log('end');

// Output:
// 'start'
// 'end'
// 'callback'

setTimeout() fügt seinen Parameter zur Aufgabenwarteschlange hinzu. Der Parameter wird daher irgendwann nach Abschluss des aktuellen Codefragments (Aufgabe) ausgeführt.

Der Parameter ms gibt nur an, wann die Aufgabe in die Warteschlange gestellt wird, nicht wann genau sie ausgeführt wird. Sie wird möglicherweise nie ausgeführt – zum Beispiel, wenn eine Aufgabe davor in der Warteschlange steht, die niemals endet. Das erklärt, warum der vorherige Code 'end' vor 'callback' protokolliert, obwohl der Parameter ms 0 ist.

39.5 Muster zur Bereitstellung asynchroner Ergebnisse

Um zu vermeiden, dass der Hauptprozess blockiert wird, während auf den Abschluss einer langlaufenden Operation gewartet wird, werden Ergebnisse in JavaScript häufig asynchron geliefert. Dies sind drei beliebte Muster dafür

Die ersten beiden Muster werden in den nächsten beiden Unterabschnitten erklärt. Promises werden im nächsten Kapitel erklärt.

39.5.1 Bereitstellung asynchroner Ergebnisse über Events

Events als Muster funktionieren wie folgt

Mehrere Variationen dieses Musters existieren in der JavaScript-Welt. Wir werden uns als Nächstes drei Beispiele ansehen.

39.5.1.1 Events: IndexedDB

IndexedDB ist eine Datenbank, die in Webbrowser integriert ist. Dies ist ein Beispiel für die Verwendung

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB hat eine ungewöhnliche Art, Operationen aufzurufen

39.5.1.2 Events: XMLHttpRequest

Die XMLHttpRequest API ermöglicht uns, Downloads aus einem Webbrowser heraus durchzuführen. So laden wir die Datei http://example.com/textfile.txt herunter

const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
  if (xhr.status == 200) {
    processData(xhr.responseText);
  } else {
    assert.fail(new Error(xhr.statusText));
  }
};
xhr.onerror = () => { // (D)
  assert.fail(new Error('Network error'));
};
xhr.send(); // (E)

function processData(str) {
  assert.equal(str, 'Content of textfile.txt\n');
}

Mit dieser API erstellen wir zuerst ein Request-Objekt (Zeile A), konfigurieren es dann und aktivieren es dann (Zeile E). Die Konfiguration besteht aus

39.5.1.3 Events: DOM

DOM-Events haben wir bereits in §39.4.1 „Die Benutzeroberfläche des Browsers kann blockiert werden“ gesehen. Der folgende Code behandelt auch click-Events

const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)

function clickListener(event) {
  event.preventDefault(); // (C)
  console.log(event.shiftKey); // (D)
}

Wir bitten den Browser zuerst, das HTML-Element mit der ID 'my-link' abzurufen (Zeile A). Dann fügen wir einen Listener für alle click-Events hinzu (Zeile B). Im Listener weisen wir den Browser zuerst an, seine Standardaktion nicht auszuführen (Zeile C) – das Navigieren zum Ziel des Links. Dann protokollieren wir in die Konsole, ob die Umschalttaste gerade gedrückt ist (Zeile D).

39.5.2 Bereitstellung asynchroner Ergebnisse über Callbacks

Callbacks sind ein weiteres Muster zur Behandlung asynchroner Ergebnisse. Sie werden nur für einmalige Ergebnisse verwendet und haben den Vorteil, weniger umständlich als Events zu sein.

Als Beispiel betrachten wir eine Funktion readFile(), die eine Textdatei liest und ihren Inhalt asynchron zurückgibt. So rufen Sie readFile() auf, wenn sie Node.js-Style-Callbacks verwendet

readFile('some-file.txt', {encoding: 'utf8'},
  (error, data) => {
    if (error) {
      assert.fail(error);
      return;
    }
    assert.equal(data, 'The content of some-file.txt\n');
  });

Es gibt einen einzelnen Callback, der sowohl Erfolg als auch Misserfolg behandelt. Wenn der erste Parameter nicht null ist, ist ein Fehler aufgetreten. Andernfalls befindet sich das Ergebnis im zweiten Parameter.

  Übungen: Callback-basierter Code

Die folgenden Übungen verwenden Tests für asynchronen Code, die sich von Tests für synchronen Code unterscheiden. Konsultieren Sie §10.3.2 „Asynchrone Tests in Mocha“ für weitere Informationen.

39.6 Asynchroner Code: die Nachteile

In vielen Situationen, sowohl in Browsern als auch in Node.js, haben Sie keine Wahl, Sie müssen asynchronen Code verwenden. In diesem Kapitel haben wir mehrere Muster gesehen, die solcher Code verwenden kann. Alle haben zwei Nachteile

Der erste Nachteil wird mit Promises (behandelt im nächsten Kapitel) weniger gravierend und verschwindet größtenteils mit Async-Funktionen (behandelt im darauf folgenden Kapitel).

Leider verschwindet die Ansteckung von asynchronem Code nicht. Aber sie wird dadurch gemildert, dass der Wechsel zwischen synchron und asynchron mit Async-Funktionen einfach ist.

39.7 Ressourcen