Dieses Kapitel erklärt die Grundlagen der asynchronen 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.
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);
}
}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.
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
divideCallback() sendet eine Anfrage an einen Server.main() beendet und andere Aufgaben können ausgeführt werden.Ein Fehler err: Dann wird die folgende Aufgabe zur Warteschlange hinzugefügt.
taskQueue.enqueue(() => callback(err));Ein result-Wert: Dann wird die folgende Aufgabe zur Warteschlange hinzugefügt.
taskQueue.enqueue(() => callback(null, result));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));
}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.
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);
// doneAnfangs, 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
f())Nach dem Funktionsaufruf h(y + 1) in Zeile 6 hat der Stack drei Einträge
g())f())Das Protokollieren von error in Zeile 3 erzeugt die folgende Ausgabe
DEBUGError:
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.
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.
Zwei Parteien greifen auf die Aufgabenwarteschlange zu
Aufgabenquellen fügen Aufgaben zur Warteschlange hinzu. Einige dieser Quellen laufen parallel zum JavaScript-Prozess. Zum Beispiel kümmert sich eine Aufgabenquelle um Benutzeroberflächen-Events: Wenn ein Benutzer irgendwo klickt und ein Klick-Listener registriert wurde, wird die Ausführung dieses Listeners zur Aufgabenwarteschlange hinzugefügt.
Die Event-Schleife läuft kontinuierlich im JavaScript-Prozess. Während jeder Iteration der Schleife nimmt sie eine Aufgabe aus der Warteschlange (wenn die Warteschlange leer ist, wartet sie, bis sie nicht mehr leer ist) und führt sie aus. Diese Aufgabe ist abgeschlossen, wenn der Call Stack leer ist und ein return erfolgt. Die Kontrolle geht zurück an die Event-Schleife, die dann die nächste Aufgabe aus der Warteschlange abruft und ausführt. Und so weiter.
Der folgende JavaScript-Code ist eine Annäherung an die Event-Schleife
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}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
demos/async-js/blocking.htmlDas 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
doBlock() aufzurufen, wenn das HTML-Element mit der ID block geklickt wird.doBlock() zeigt Statusinformationen an und ruft dann sleep() auf, um den JavaScript-Prozess für 5000 Millisekunden zu blockieren (Zeile B).sleep() blockiert den JavaScript-Prozess, indem es schleift, bis genügend Zeit vergangen ist.displayStatus() zeigt Statusmeldungen innerhalb des <div> mit der ID statusMessage an.Es gibt verschiedene Möglichkeiten, eine langlaufende Operation daran zu hindern, den Browser zu blockieren
Die Operation kann ihr Ergebnis asynchron liefern: Einige Operationen, wie z. B. Downloads, können parallel zum JavaScript-Prozess ausgeführt werden. Der JavaScript-Code, der eine solche Operation auslöst, registriert einen Callback, der mit dem Ergebnis aufgerufen wird, sobald die Operation abgeschlossen ist. Der Aufruf wird über die Aufgabenwarteschlange behandelt. Diese Art der Ergebnisübermittlung wird als asynchron bezeichnet, da der Aufrufer nicht auf die Bereitschaft der Ergebnisse wartet. Normale Funktionsaufrufe liefern ihre Ergebnisse synchron.
Langlaufende Berechnungen in separaten Prozessen durchführen: Dies kann über sogenannte Web Worker geschehen. Web Worker sind schwere Prozesse, die parallel zum Hauptprozess laufen. Jeder von ihnen hat seine eigene Laufzeitumgebung (globale Variablen usw.). Sie sind vollständig isoliert und müssen über Nachrichtenübergabe kommunizieren. Konsultieren Sie die MDN-Webdokumentation für weitere Informationen.
Pausen bei langlaufenden Berechnungen machen. Der nächste Unterabschnitt erklärt, wie.
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): anyDie 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): voidsetTimeout() 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.
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.
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.
Events als Muster funktionieren wie folgt
Mehrere Variationen dieses Musters existieren in der JavaScript-Welt. Wir werden uns als Nächstes drei Beispiele ansehen.
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
Jede Operation hat eine zugehörige Methode zur Erstellung von Request-Objekten. Zum Beispiel ist in Zeile A die Operation "öffnen", die Methode ist .open() und das Request-Objekt ist openRequest.
Die Parameter für die Operation werden über das Request-Objekt bereitgestellt, nicht über Parameter der Methode. Zum Beispiel sind die Event-Listener (Funktionen) in den Eigenschaften .onsuccess und .onerror gespeichert.
Der Aufruf der Operation wird über die Methode (in Zeile A) zur Aufgabenwarteschlange hinzugefügt. Das heißt, wir konfigurieren die Operation, nachdem ihr Aufruf bereits zur Warteschlange hinzugefügt wurde. Nur die Run-to-completion-Semantik rettet uns hier vor Race Conditions und stellt sicher, dass die Operation nach Abschluss des aktuellen Codefragments ausgeführt wird.
XMLHttpRequestDie 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
GET, POST, PUT usw.xhr geliefert werden. (Ich bin kein Fan dieser Art von Mischung von Eingabe- und Ausgabedaten.)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).
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.
exercises/async-js/read_file_cb_exrc.mjs.map(): exercises/async-js/map_cb_test.mjsIn 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.