Inhaltsverzeichnis
Bitte unterstützen Sie dieses Buch: kaufen Sie es (PDF, EPUB, MOBI) oder spenden Sie
(Werbung, bitte nicht blockieren.)

5. Async-Funktionen

Die ECMAScript 2017-Funktion „Async Functions“ wurde von Brian Terlson vorgeschlagen.

5.1 Übersicht

5.1.1 Varianten

Die folgenden Varianten von Async-Funktionen existieren. Beachten Sie überall das Schlüsselwort async.

5.1.2 Async-Funktionen geben immer Promises zurück

Erfüllen des Promises einer Async-Funktion

async function asyncFunc() {
    return 123;
}

asyncFunc()
.then(x => console.log(x));
    // 123

Ablehnen des Promises einer Async-Funktion

async function asyncFunc() {
    throw new Error('Problem!');
}

asyncFunc()
.catch(err => console.log(err));
    // Error: Problem!

5.1.3 Behandeln von Ergebnissen und Fehlern asynchroner Berechnungen über await

Der Operator await (der nur innerhalb von Async-Funktionen erlaubt ist) wartet darauf, dass sein Operand, ein Promise, settled (abgeschlossen) ist.

Behandeln eines einzelnen asynchronen Ergebnisses

async function asyncFunc() {
    const result = await otherAsyncFunc();
    console.log(result);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .then(result => {
        console.log(result);
    });
}

Behandeln mehrerer asynchroner Ergebnisse sequenziell

async function asyncFunc() {
    const result1 = await otherAsyncFunc1();
    console.log(result1);
    const result2 = await otherAsyncFunc2();
    console.log(result2);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc1()
    .then(result1 => {
        console.log(result1);
        return otherAsyncFunc2();
    })
    .then(result2 => {
        console.log(result2);
    });
}

Behandeln mehrerer asynchroner Ergebnisse parallel

async function asyncFunc() {
    const [result1, result2] = await Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ]);
    console.log(result1, result2);
}

// Equivalent to:
function asyncFunc() {
    return Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ])
    .then([result1, result2] => {
        console.log(result1, result2);
    });
}

Fehlerbehandlung

async function asyncFunc() {
    try {
        await otherAsyncFunc();
    } catch (err) {
        console.error(err);
    }
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .catch(err => {
        console.error(err);
    });
}

5.2 Verständnis von Async-Funktionen

Bevor ich Async-Funktionen erklären kann, muss ich erklären, wie Promises und Generatoren kombiniert werden können, um asynchrone Operationen über synchron aussehenden Code durchzuführen.

Für Funktionen, die ihre Einzelergebnisse asynchron berechnen, sind Promises, die Teil von ES6 sind, beliebt geworden. Ein Beispiel ist die clientseitige fetch API, die eine Alternative zu XMLHttpRequest zum Abrufen von Dateien ist. Die Verwendung sieht wie folgt aus

function fetchJson(url) {
    return fetch(url)
    .then(request => request.text())
    .then(text => {
        return JSON.parse(text);
    })
    .catch(error => {
        console.log(`ERROR: ${error.stack}`);
    });
}
fetchJson('http://example.com/some_file.json')
.then(obj => console.log(obj));

5.2.1 Asynchronen Code mit Generatoren schreiben

co ist eine Bibliothek, die Promises und Generatoren verwendet, um einen Programmierstil zu ermöglichen, der synchroner aussieht, aber genauso funktioniert wie der Stil im vorherigen Beispiel

const fetchJson = co.wrap(function* (url) {
    try {
        let request = yield fetch(url);
        let text = yield request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
});

Jedes Mal, wenn der Callback (eine Generatorfunktion!) ein Promise an co übergibt, wird der Callback angehalten. Sobald das Promise settled ist, setzt co den Callback fort: Wenn das Promise erfüllt wurde, gibt yield den Erfüllungswert zurück, wenn es abgelehnt wurde, wirft yield den Ablehnungsfehler. Zusätzlich promissifiziert co das vom Callback zurückgegebene Ergebnis (ähnlich wie then() es tut).

5.2.2 Asynchronen Code mit Async-Funktionen schreiben

Async-Funktionen sind im Grunde dedizierte Syntax für das, was co tut.

async function fetchJson(url) {
    try {
        let request = await fetch(url);
        let text = await request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
}

Intern funktionieren Async-Funktionen weitgehend wie Generatoren.

5.2.3 Async-Funktionen werden synchron gestartet, asynchron beendet

So werden Async-Funktionen ausgeführt.

  1. Das Ergebnis einer Async-Funktion ist immer ein Promise p. Dieses Promise wird erstellt, wenn die Ausführung der Async-Funktion gestartet wird.
  2. Der Körper wird ausgeführt. Die Ausführung kann dauerhaft über return oder throw enden. Oder sie kann temporär über await enden; in diesem Fall wird die Ausführung normalerweise später fortgesetzt.
  3. Das Promise p wird zurückgegeben.

Während der Ausführung des Körpers der Async-Funktion löst return x das Promise p mit x auf, während throw err p mit err ablehnt. Die Benachrichtigung über eine Beendigung erfolgt asynchron. Mit anderen Worten: Die Callbacks von then() und catch() werden immer ausgeführt, nachdem der aktuelle Code abgeschlossen ist.

Der folgende Code zeigt, wie das funktioniert.

async function asyncFunc() {
    console.log('asyncFunc()'); // (A)
    return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)

// Output:
// asyncFunc()
// main
// Resolved: abc

Sie können sich auf die folgende Reihenfolge verlassen.

  1. Zeile (A): Die Async-Funktion wird synchron gestartet. Das Promise der Async-Funktion wird über return aufgelöst.
  2. Zeile (C): Die Ausführung wird fortgesetzt.
  3. Zeile (B): Die Benachrichtigung über die Promise-Auflösung erfolgt asynchron.

5.2.4 Zurückgegebene Promises werden nicht verpackt

Das Auflösen eines Promises ist eine Standardoperation. return verwendet sie, um das Promise p einer Async-Funktion aufzulösen. Das bedeutet:

  1. Das Zurückgeben eines Nicht-Promise-Wertes erfüllt p mit diesem Wert.
  2. Das Zurückgeben eines Promises bedeutet, dass p nun den Zustand dieses Promises widerspiegelt.

Daher können Sie ein Promise zurückgeben, und dieses Promise wird nicht in ein Promise verpackt.

async function asyncFunc() {
    return Promise.resolve(123);
}
asyncFunc()
.then(x => console.log(x)) // 123

Interessanterweise führt die Rückgabe eines abgelehnten Promises dazu, dass das Ergebnis der Async-Funktion abgelehnt wird (normalerweise würden Sie dafür throw verwenden).

async function asyncFunc() {
    return Promise.reject(new Error('Problem!'));
}
asyncFunc()
.catch(err => console.error(err)); // Error: Problem!

Dies steht im Einklang damit, wie die Promise-Auflösung funktioniert. Es ermöglicht Ihnen, sowohl Erfüllungen als auch Ablehnungen einer anderen asynchronen Berechnung weiterzuleiten, ohne await zu verwenden.

async function asyncFunc() {
    return anotherAsyncFunc();
}

Der vorherige Code ist ungefähr ähnlich wie – aber effizienter als – der folgende Code (der das Promise von anotherAsyncFunc() nur entpackt, um es erneut zu verpacken).

async function asyncFunc() {
    return await anotherAsyncFunc();
}

5.3 Tipps zur Verwendung von await

5.3.1 await nicht vergessen

Ein leicht zu machender Fehler in Async-Funktionen ist das Vergessen von await bei einem asynchronen Funktionsaufruf.

async function asyncFunc() {
    const value = otherAsyncFunc(); // missing `await`!
    ···
}

In diesem Beispiel wird value auf ein Promise gesetzt, was in Async-Funktionen normalerweise nicht gewünscht ist.

await kann sogar sinnvoll sein, wenn eine Async-Funktion nichts zurückgibt. Dann wird ihr Promise einfach als Signal verwendet, um dem Aufrufer mitzuteilen, dass sie beendet ist. Zum Beispiel:

async function foo() {
    await step1(); // (A)
    ···
}

Das await in Zeile (A) garantiert, dass step1() vollständig abgeschlossen ist, bevor der Rest von foo() ausgeführt wird.

5.3.2 Sie brauchen kein await, wenn Sie „feuern und vergessen“

Manchmal möchten Sie nur eine asynchrone Berechnung auslösen und sind nicht daran interessiert, wann sie abgeschlossen ist. Der folgende Code ist ein Beispiel.

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello'); // don’t wait
    writer.write('world'); // don’t wait
    await writer.close(); // wait for file to close
}

Hier ist es uns egal, wann einzelne Schreibvorgänge abgeschlossen sind, nur dass sie in der richtigen Reihenfolge ausgeführt werden (was die API garantieren müsste, aber durch das Ausführungsmodell von Async-Funktionen gefördert wird – wie wir gesehen haben).

Das await in der letzten Zeile von asyncFunc() stellt sicher, dass die Funktion erst erfüllt wird, nachdem die Datei erfolgreich geschlossen wurde.

Da zurückgegebene Promises nicht verpackt werden, können Sie anstelle von await writer.close() auch return writer.close() verwenden.

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello');
    writer.write('world');
    return writer.close();
}

Beide Versionen haben Vor- und Nachteile, die await-Version ist wahrscheinlich etwas leichter zu verstehen.

5.3.3 await ist sequenziell, Promise.all() ist parallel

Der folgende Code macht zwei asynchrone Funktionsaufrufe: asyncFunc1() und asyncFunc2().

async function foo() {
    const result1 = await asyncFunc1();
    const result2 = await asyncFunc2();
}

Diese beiden Funktionsaufrufe werden jedoch sequenziell ausgeführt. Die parallele Ausführung beschleunigt die Dinge tendenziell. Sie können Promise.all() verwenden, um dies zu tun.

async function foo() {
    const [result1, result2] = await Promise.all([
        asyncFunc1(),
        asyncFunc2(),
    ]);
}

Anstatt zwei Promises abzuwarten, warten wir nun auf ein Promise für ein Array mit zwei Elementen.

5.4 Async-Funktionen und Callbacks

Eine Einschränkung von Async-Funktionen ist, dass await nur die direkt umschließende Async-Funktion beeinflusst. Daher kann eine Async-Funktion nicht in einem Callback awaiten (jedoch können Callbacks selbst Async-Funktionen sein, wie wir später sehen werden). Das macht Callback-basierte Hilfsfunktionen und Methoden schwierig zu verwenden. Beispiele hierfür sind die Array-Methoden map() und forEach().

5.4.1 Array.prototype.map()

Beginnen wir mit der Array-Methode map(). Im folgenden Code möchten wir die Dateien herunterladen, auf die ein Array von URLs verweist, und sie in einem Array zurückgeben.

async function downloadContent(urls) {
    return urls.map(url => {
        // Wrong syntax!
        const content = await httpGet(url);
        return content;
    });
}

Dies funktioniert nicht, da await in normalen Pfeilfunktionen syntaktisch ungültig ist. Wie wäre es stattdessen mit einer Async-Pfeilfunktion?

async function downloadContent(urls) {
    return urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
}

Es gibt zwei Probleme mit diesem Code.

Wir können beide Probleme mit Promise.all() beheben, das ein Array von Promises in ein Promise für ein Array umwandelt (mit den von den Promises erfüllten Werten).

async function downloadContent(urls) {
    const promiseArray = urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
    return await Promise.all(promiseArray);
}

Der Callback für map() macht nicht viel mit dem Ergebnis von httpGet(), er leitet es nur weiter. Daher benötigen wir hier keine Async-Pfeilfunktion, eine normale Pfeilfunktion genügt.

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return await Promise.all(promiseArray);
}

Es gibt noch eine kleine Verbesserung, die wir vornehmen können: Diese Async-Funktion ist etwas ineffizient – sie entpackt zuerst das Ergebnis von Promise.all() über await, bevor sie es über return erneut verpackt. Da return Promises nicht verpackt, können wir das Ergebnis von Promise.all() direkt zurückgeben.

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return Promise.all(promiseArray);
}

5.4.2 Array.prototype.forEach()

Verwenden wir die Array-Methode forEach(), um den Inhalt mehrerer Dateien zu protokollieren, auf die über URLs verwiesen wird.

async function logContent(urls) {
    urls.forEach(url => {
        // Wrong syntax
        const content = await httpGet(url);
        console.log(content);
    });
}

Auch dieser Code führt zu einem Syntaxfehler, da Sie await nicht in normalen Pfeilfunktionen verwenden können.

Verwenden wir eine Async-Pfeilfunktion.

async function logContent(urls) {
    urls.forEach(async url => {
        const content = await httpGet(url);
        console.log(content);
    });
    // Not finished here
}

Dies funktioniert, aber es gibt einen Vorbehalt: Das von httpGet() zurückgegebene Promise wird asynchron aufgelöst, was bedeutet, dass die Callbacks nicht fertig sind, wenn forEach() zurückkehrt. Infolgedessen können Sie nicht auf das Ende von logContent() warten.

Wenn Sie das nicht möchten, können Sie forEach() in eine for-of-Schleife umwandeln.

async function logContent(urls) {
    for (const url of urls) {
        const content = await httpGet(url);
        console.log(content);
    }
}

Jetzt ist alles nach der for-of-Schleife fertig. Die Verarbeitungsschritte erfolgen jedoch sequenziell: httpGet() wird erst aufgerufen, wenn der erste Aufruf abgeschlossen ist. Wenn Sie möchten, dass die Verarbeitungsschritte parallel erfolgen, müssen Sie Promise.all() verwenden.

async function logContent(urls) {
    await Promise.all(urls.map(
        async url => {
            const content = await httpGet(url);
            console.log(content);
        }));
}

map() wird verwendet, um ein Array von Promises zu erstellen. Wir sind nicht an den Ergebnissen interessiert, die sie erfüllen, wir awaiten nur, bis alle erfüllt sind. Das bedeutet, dass wir am Ende dieser Async-Funktion vollständig fertig sind. Wir könnten genauso gut Promise.all() zurückgeben, aber dann wäre das Ergebnis der Funktion ein Array, dessen Elemente alle undefined sind.

5.5 Tipps zur Verwendung von Async-Funktionen

5.5.1 Kennen Sie Ihre Promises

Die Grundlage von Async-Funktionen sind Promises. Deshalb ist das Verständnis der letzteren entscheidend für das Verständnis der ersteren. Besonders beim Verbinden von altem Code, der nicht auf Promises basiert, mit Async-Funktionen, hat man oft keine andere Wahl, als Promises direkt zu verwenden.

Dies ist zum Beispiel eine „promisifizierte“ Version von XMLHttpRequest.

function httpGet(url, responseType="") {
    return new Promise(
        function (resolve, reject) {
            const request = new XMLHttpRequest();
            request.onload = function () {
                if (this.status === 200) {
                    // Success
                    resolve(this.response);
                } else {
                    // Something went wrong (404 etc.)
                    reject(new Error(this.statusText));
                }
            };
            request.onerror = function () {
                reject(new Error(
                    'XMLHttpRequest Error: '+this.statusText));
            };
            request.open('GET', url);
            xhr.responseType = responseType;
            request.send();
        });
}

Die API von XMLHttpRequest basiert auf Callbacks. Das Promisifizieren mit einer Async-Funktion würde bedeuten, dass Sie das von der Funktion zurückgegebene Promise aus Callbacks heraus erfüllen oder ablehnen müssten. Das ist unmöglich, da Sie dies nur über return und throw tun können. Und Sie können das Ergebnis einer Funktion nicht aus einem Callback heraus zurückgeben. throw hat ähnliche Einschränkungen.

Daher ist der übliche Programmierstil für Async-Funktionen:

Weitere Lektüre: Kapitel „Promises für asynchrone Programmierung“ in „Exploring ES6“.

5.5.2 Sofort aufgerufene Async-Funktionsausdrücke

Manchmal wäre es schön, wenn man await auf der obersten Ebene eines Moduls oder Skripts verwenden könnte. Leider ist es nur innerhalb von Async-Funktionen verfügbar. Sie haben daher mehrere Möglichkeiten. Sie können entweder eine Async-Funktion main() erstellen und sie sofort danach aufrufen.

async function main() {
    console.log(await asyncFunction());
}
main();

Oder Sie können einen sofort aufgerufenen Async-Funktionsausdruck verwenden.

(async function () {
    console.log(await asyncFunction());
})();

Eine weitere Option ist eine sofort aufgerufene Async-Pfeilfunktion.

(async () => {
    console.log(await asyncFunction());
})();

5.5.3 Unit-Tests mit Async-Funktionen

Der folgende Code verwendet das Test-Framework Mocha, um die asynchronen Funktionen asyncFunc1() und asyncFunc2() zu testen.

import assert from 'assert';

// Bug: the following test always succeeds
test('Testing async code', function () {
    asyncFunc1() // (A)
    .then(result1 => {
        assert.strictEqual(result1, 'a'); // (B)
        return asyncFunc2();
    })
    .then(result2 => {
        assert.strictEqual(result2, 'b'); // (C)
    });
});

Dieser Test ist jedoch immer erfolgreich, da Mocha nicht darauf wartet, bis die Assertions in Zeile (B) und Zeile (C) ausgeführt wurden.

Sie können dies beheben, indem Sie das Ergebnis der Promise-Kette zurückgeben, da Mocha erkennt, wenn ein Test ein Promise zurückgibt, und dann darauf wartet, bis dieses Promise settled ist (es sei denn, es gibt ein Timeout).

return asyncFunc1() // (A)

Praktischerweise geben Async-Funktionen immer Promises zurück, was sie für diese Art von Unit-Tests perfekt macht.

import assert from 'assert';
test('Testing async code', async function () {
    const result1 = await asyncFunc1();
    assert.strictEqual(result1, 'a');
    const result2 = await asyncFunc2();
    assert.strictEqual(result2, 'b');
});

Es gibt also zwei Vorteile bei der Verwendung von Async-Funktionen für asynchrone Unit-Tests in Mocha: Der Code ist prägnanter und die Rückgabe von Promises wird ebenfalls erledigt.

5.5.4 Machen Sie sich keine Sorgen über unbehandelte Ablehnungen

JavaScript-Engines werden immer besser darin, vor nicht behandelten Ablehnungen zu warnen. Der folgende Code wäre beispielsweise in der Vergangenheit oft stillschweigend fehlgeschlagen, aber die meisten modernen JavaScript-Engines melden nun eine unbehandelte Ablehnung.

async function foo() {
    throw new Error('Problem!');
}
foo();

5.6 Weitere Lektüre

Weiter: 6. Gemeinsamer Speicher und Atomare Operationen