Geschrieben von

Asynchrone Programmierung in JavaScript

WebDev

Stell dir vor du arbeitest in einem Unternehmen mit insgesamt 5 Kollegen. Ihr teilt euch ein Büro. Damit du jedoch deine Arbeit ausführen und beginnen kannst, musst du jedes Mal warten bis deine 5 Kollegen nacheinander ihre Arbeit fertig verrichtet haben. Erst dann kannst du zum Schreibtisch und arbeiten.

Diesen Fall wird man so zwar nicht in der Praxis finden, soll jedoch kurz ein einfaches Beispiel zeigen, was synchrone Programmierung heißt. Sprich – bevor mit einer Arbeit begonnen werden kann, muss die zuvor begonnene Arbeit abgeschlossen werden.

Nehmen wir das obige Beispiel und wenden es auf ein asynchrones Beispiel an, dann könnten alle Personen ihre Arbeit gleichzeitig und parallel verrichten. Sprich – ein Programm kann an mehreren Aufgaben gleichzeitig arbeiten.

Mehr dazu in diesem Beitrag.

Was ist synchrone Programmierung?

Synchrone Programmierung bedeutet, dass ein Programm einzelne Aufgaben nacheinander abarbeitet. Stell dir vor, du kochst was und hast folgende Aufgaben:

  • Wasser aufkochen
  • Nudeln aufkochen
  • Gemüse schneiden

In einer synchronen Welt würdest du die Aufgaben nacheinander ausführen und mit einer Aufgabe erst beginnen, wenn die vorherige abgeschlossen ist. In JavaScript würde das so aussehen:

function wasserKochen () {
    console.log("Wasser fertig gekocht.");
};

function nudelnKochen () {
    console.log("Nudeln fertig gekocht.");
};

function gemueseSchneiden () {
    console.log("Gemüse fertig geschnitten");
};

Jetzt führen wir diese Funktionen aus…:

wasserKochen();
nudelnKochen();
gemueseSchneiden();

…und bekommen folgendes Ergebnis:

Wasser fertig gekocht.
Nudeln fertig gekocht.
Gemüse fertig geschnitten.

Wie man sehen kann werden die einzelnen Aufgaben nacheinander ausgeführt. Synchrone Programmierung kann jedoch manchmal ineffizient und problematisch werden. Zwei Beispiele:

  • Während Wasser und Nudeln aufkochen, könnten wir währenddessen Gemüse schon schneiden und so Zeit sparen.
  • Wenn wir erst das Gemüse nachdem Wasser und Nudeln fertig sind, anfangen zu schneiden, werden die Nudeln kalt.

Ein klassisches synchrones Beispiel aus der Programmierung wäre ein Server-Request. Wenn wir einen Request durchführen und dann auf die Antwort des Servers warten, wird dieses Warten in der synchronen Programmierung die Ausführen des weiteren Programmes blockieren, obwohl die Aufgabe (Request durchführen, auf Antwort warten, Antwort verarbeiten) parallel zum restlichen Programm ablaufen könnte.

Dieses Verhalten kann man wie folgt simulieren:

function serverTask() {
    let start = Date.now();
    while (Date.now() - start < 5000) {
      // Hier kommt eine Logik
    }
    return "Server fertig";
}

console.log("Start");

let result = serverTask();
console.log(result);

console.log("Weitermachen");

Hier wird zunächst “Start” geloggt, dann dauert es 5 Sekunden bis “Server fertig” geloggt wird. Erst dann kann die Aufgabe mit dem Log “Weitermachen” fortgeführt werden.

Solche Fälle können dazu führen, dass eine Aufgabe sehr lange dauert und der Nutzer - bis die Aufgabe fertig ist - nicht mit der Website interagieren kann. Um das zu vermeiden, gibt es die asynchrone Programmierung. Dabei werden die Aufgaben parallel durchgeführt und warten nicht, bis erst vorher ausgeführte Aufgaben fertig sind.

Was ist asynchrone Programmierung?

Basierend auf unserem letzten Beispiel mit dem Server-Request, erlaubt die asynchrone Programmierung weiterzuarbeiten bis der Response zurückgeliefert wurde. Das hat zur Folge, dass Interaktivität und Performance des Programms verbessert werden.

Hier ein Beispiel wie ein asynchrones Programm ablaufen kann:

console.log("Start");
setTimeout(function() {
    console.log("Server fertig");
}, 3000);
console.log("Weitermachen");

Hier wird zunächst “Start” ausgegeben, gefolgt von “Weitermachen” und nach 3 Sekunden folgt “Server fertig”, da die übergebene Funktion in setTimeout asynchron läuft. Sprich: Das Programm wartet nicht 3 Sekunden, um zuerst “Server fertig” zu loggen und dann erst weiterzumachen mit der nächsten Code-Zeile, um “Weitermachen” zu loggen.

In JavaScript gibt es diverse Möglichkeiten asynchrone Abläufe zu steuern. Neben integrierten Funktionen wie setTimeout, die standardmäßig asynchron laufen, gibt es weitere Methoden.

Asynchrone Techniken in JavaScript

Callbacks

Über Callbacks hatte ich hier schon geschrieben, daher gibt es an dieser Stelle nur eine kurze Erklärung und Zusammenfassung.

Gehen wir davon aus wir möchten Daten von einem Server abfragen. Während die Daten abgefragt, verarbeitet und geloggt werden, sollte dies das restliche Programm nicht beeinflussen. Dies können wir mit Callbacks erreichen. Ein Callback ist eine Funktion, die als Argument in einer anderen Funktion übergeben wird und erst dann ausgeführt wird, wenn die erste Funktion fertig ist.

Nehmen wir dazu folgendes Beispiel:

function getData(callback) {
    setTimeout(() => {
        const data = {'name': 'Demir Jasarevic'};
        callback(data);
    }, 4000);
};

getData(function(data) {
    console.log(data);
});

console.log("Daten werden abgefragt...");

Hier sehen wir, dass zunächst die Funktion “getData” definiert wird. Im Anschluss wird sie ausgerufen, jedoch wird hier eine Funktion als Argument übergeben (= Callback-Funktion). Danach folgt eine Code-Zeile, die “Daten werden abgefragt…” loggt. Was ist das Ergebnis? In einer synchronen Welt würden die ersten beiden Punkte (Funktion definieren und aufrufen) den kompletten weiteren Code und dessen Ausführung blockieren. Jedoch, in unserem Fall bekommen wir folgendes Ergebnis:

Daten werden abgefragt…
{name: 'Demir Jasarevic'}

Es wird also nicht gewartet bis zunächst die Daten vom Server geloggt werden, JavaScript fährt mit der Code-Ausführung fort und loggt zu einem späteren Zeitpunkt die Daten, da die getData-Funktion asynchron abläuft.

Callbacks sind eine Möglichkeit asynchrone Operationen durchführen. Werden mehrere Callbacks verschachtelt, wird der Code recht schnell komplex und unübersichtlich. Hier ein Beispiel:

doSomething(function(response) {
  doSomethingElse(response, function(nextResponse) {
    doThirdThing(nextResponse, function(finalResponse) {
      console.log('Final result: ' + finalResponse);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

Diese Praktik wird auch als “Callback Hell” bezeichnet und sollte vermieden werden. Eine gute Alternative sind Promises.

Promises

Über Promises habe ich in diesem Beitrag ausführlicher geschrieben, daher werden hier nur die wesentlichen Punkte erwähnt. Für mehr Details besuche bitte meinen Beitrag zu Promises.

Bei Promises handelt es sich um eine Methode asynchrone Vorgänge zu steuern. Übersetzt ins Deutsche bedeutet “Promise” einfach nur “Versprechen”. Im Grunde ist ein Promise ein Platzhalter für einen Wert, der erst zukünftig bekannt ist. Man “verspricht”, dass etwas in Zukunft garantiert stattfinden wird.

Wie das technisch funktioniert? Es werden Rückruffunktionen an das Promise angehängt, die eine bestimmte Aktion durchführen, wenn das Versprechen erfüllt oder abgelehnt wurde. Um ein Promise zu erstellen erstellt man eine neue Instanz des Promise-Objekts:

const myPromise = new Promise((resolve, reject) => {});

Zu Erklärung:

  • Das Promise-Objekt wird erstellt, indem der Promise-Konstruktor aufgerufen wird.
  • Der Konstruktor erwartet eine Steuer-Funktion als Argument (Executer Function)
  • Die Steuer-Funktion nimmt zwei Argumente entgegen: resolve und reject.

Wenn wir nun unser Promise loggen bekommen wir folgende Infos:

Wir sehen einen Status von “pending” und ein Resultat von “undefined”. Der Grund ist, dass wir einfach ein Promise-Objekt erstellt haben, ohne das entsprechende Handling zu implementieren bzw. dessen Auflösung.

Um es aufzulösen haben wir zwei Möglichkeiten:

  • Nutzung des Arguments “resolve”.
  • Nutzung des Arguments “reject”.

Was diese zwei Argumente genau machen, dazu komme ich gleich. Aber zunächst einmal zwei einfache Beispiele dazu. Starten wir mit der Auflösung des Promises mittels “resolve”:

const myPromise = new Promise((resolve, reject) => {
   resolve("Hallo, ich bin resolve.");
});

Loggen wir nun unsere myPromise-Konstante, dann erhalten wir folgendes Ergebnis:

Der Status hat sich nun auf “fulfilled” geändert und das Resultat ebenso. Nun das gleiche mit “reject”:

const myPromise = new Promise((resolve, reject) => {
   reject("Hallo, ich bin reject.");
});

Beim loggen von myPromise sehen wir folgendes Ergebnis:

Der Status lautet “rejected” und das Resultat ist gleich unserem übergebenen Text in der reject-Funktion. Wie man bisher sehen kann, kann ein Promise drei Status annehmen:

  • pending: Das ist der Ausgangszustand, der sozusagen “ausstehend” ist, da noch nicht erfüllt (resolve) oder abgelehnt (reject).
  • fulfilled: Der Vorgang wurde erfolgreich abgeschlossen.
  • rejected: Der Vorgang ist fehlgeschlagen.

Um mit dem erzeugten Promise im nächsten Schritt zu arbeiten, stehen und die zwei Methoden ".then" und ".catch" zur Verfügung:

  • Die .then()-Methode wird aufgerufen, wenn das Promise den Status “fulfilled” bekommt.
  • Die .catch()-Methode wird aufgerufen, wenn das Promise den Status “rejected” bekommt.

Um ein Promise nun zu konsumieren, können wir uns an folgende 3 Schritte halten:

  1. Zunächst muss auf das erstellte Promise referenziert werden.
  2. Die Callbacks .then() und .catch() an das Promise anhängen.
  3. Auf das Promise warten, bis es erfolgreich aufgelöst oder abgelehnt wird.

So sieht das Ganze dann aus:

const myPromise = new Promise((resolve, reject) => {
    try {
       resolve("Hallo, ich bin resolve.");
    } catch (e) {
        reject("Hallo, ich bin reject.");
    };
});

myPromise
  .then((result) => {
    console.log(result);
  })
  .then((error) => {
    console.log(error);
  });

Bei diesem Beispiel wird das .then() ausgeführt, wenn das Promise "fulfilled" ist und .catch() ausgeführt, wenn das Promise "rejected" wird. Es gibt noch die Möglichkeit ein .finally() anzuhängen, ein Callback, der immer ausgeführt wird. Also auch unabhängig wie der Status am Ende lautet. Die Syntax wäre dabei wie folgt:

myPromise
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
    // Code, der immer ausgeführt wird
  });

Wie man sehen kann entstehen bei Promises eine klare Verkettung. Das Promise Chaining ist ein Muster, um asynchrone Vorgänge klar zu strukturieren. Dabei wird die Ausgabe eines Callbacks als Eingabe für das nächste Callback übergeben. Sehr oft sieht man dieses Muster bei Request mit der fetch-Methode. Hier ein Beispiel:

fetch('https://www.api.com/data')
    .then(response => response.json())
    .then(data => cleanData(data))
    .then(cleanedData => {
        // Logic
    })
    .catch(error => console.log(error));

Wie vorhin erwähnt ist es wichtig zu bedenken, dass die .then-Methoden asynchron der Reihe nach ausgeführt werden. Eine .then-Methode wartet auf die Auflösung der vorherigen .then-Methode und nimmt den übergebenen Wert als Argument an.

Es gibt jedoch auch eine Methode, um mehrere Promises auszuführen und auf deren Auflösung zu warten. Das geschieht mit der Promise.all()-Methode. Nehmen wir an, wir haben 3 Promises:

let promise1 = fetch('https://jsonplaceholder.typicode.com/todos/1');
let promise2 = fetch('https://jsonplaceholder.typicode.com/todos/2');
let promise3 = fetch('https://jsonplaceholder.typicode.com/todos/3');

Mit der Promise.all()-Methode können wir nun warten bis alle drei Promises Daten zurückliefern und erst dann etwas damit machen:

Promise.all([promise1, promise2, promise3])
  .then((data) => {
    console.log(data);
  })

Asynchrone Funktionen mit async und await

Mit async und await kann man asynchronen Code in strukturierter, klarer und synchroner Weise schreiben. Beide Keywords erfüllen unterschiedliche Funktionen:

  • Mit async deklariert man eine Funktion als aynchron.
  • await wird dann in der async-Funktion genutzt, um die Ausführung der Funktion so lange zu stoppen bis das Promise aufgelöst wurde.

Hier ein Code-Beispiel:

async function getData() {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
    const data = await response.json();
    console.log(data);
}

Zur Erklärung:

  • Zunächst wird die Funktion getData als asynchron markiert, indem das Keyword "async" davor geschrieben wird.
  • Innerhalb der Funktion setzen wir vor dem fetch das Keyword "await", um auf das fetch zu warten bis die Anfrage abgeschlossen ist und wir Daten vorliegen haben.
  • Wenn die Daten dann da sind, nutzen wir nochmal await, um darauf zu warten bis die Daten vollständig geparst wurden (über response.json()).
  • Erst dann werden die Daten geloggt.

Selbiges würde man auch mit der fetch-Methode erzielen und dem Promise Chaining:

fetch('https://jsonplaceholder.typicode.com/posts/1')
    .then(response => response.json())
    .then(data => {
        console.log(data);
    })
    .catch(error => {
        console.log('Error:', error);
    });