KEY Talks #3 – Asynchrone Funktionen in Javascript

ECMAScript 2017 / ECMAScript 8 hat viele – insbesonders kleine – Neuerungen mit sich gebracht. Die wohl größte Änderung besteht in der Implementierung asynchroner Funktionen. Aber was genau sind diese asynchronen Funktionen? Bevor man diese Frage beantworten kann, benötigt man ein paar Grundlagen:

  • Wofür stehen Blocking / Non-Blocking?
  • Was versteht man unter Promise?
  • Was sind Generator Funktionen?

Blocking und Non-Blocking

Blocking heißt, dass unsere JS-Datei eine Pause einlegt und wartet, bis eine bestimmte nicht-JS Operation ausgeführt wurde. Der Event-Loop kann ohne Abschluss dieser externen Operationen nicht fortfahren. In JS ist Blocking ziemlich untypsch, da es viel zu ineffizient ist. Eine der wenigen und bekanntesten, blockenden Funktionalitäten ist alert(); Die Ausführung des JS-Codes geht erst dann weiter, wenn das Alert-Fenster geschlossen wurde.

Non-Blocking ist das Gegenteil vom Blocking. JS wartet nicht auf externe Operationen, sondern führt folgende Codestücke synchron aus. Dadurch wird das Script zwar um ein vielfaches schneller, aber es zieht auch Probleme mit sich. Eines dieser Probleme zeigt folgendes Beispiel:

var myData = loadData('/myFile');
console.log(myData);

>> undef

loadData versucht eine externe Datei einzulesen. Dies Prozess wird synchron ausgeführt! Da myData zum Zeitpunkt der Ausgabe noch nicht geladen ist, erhält man die Ausgabe >> undef.

Callbacks

Die Standardvariante, um dieses Problem zu umgehen sind Callbacks. Also Funktionen, die nach Abschluss der externen Operation ausgeführt werden. Ein Beispiel kann folgendermaßen aussehen:

loadData('/myFile', (data) => {
    console.log(data);
});

>> "Hello World"

Diese Variante ist ziemlich simpel, kann aber umständlich werden. Was passiert, wenn man mehrerer solcher externen Funktionen hintereinander ausführen muss und gleichzeitig auf alle angewiesen ist? Und wie geht man mit Errors um? Ist es dann immer noch so einfach?

var obj = {}
loadData('/myFile1', (data1, error1) => {
    obj['file1'] = data1
    loadData('/myFile2', (data2, error2) => {
        obj['file2'] = data2
        loadData('/myFile3', (data3, error3) => {
            obj['file3'] = data3
            console.log(obj);
        });
    });
});

>> {"file1" => "how", "file2" => "are", "file3" => "you"}

Was sehen wir? Man kann mehrere Callbacks verschachtelt.
Ist das schön? Nein!

Und dabei wurden hier noch nicht einmal Errors näher betrachtet! Natürlich hat man schnell gemerkt, dass Callbacks nicht das Gelbe vom Ei sind, darum hat man sich etwas Neues ausgedacht: Promises

Promises

Promises lassen sich wie normale Variablen einer Operation zuweisen. Jedoch werden sie nicht undefined, wenn die Operation noch nicht abgeschlossen wurde. Promises speichern, wie das fertige Objekt aussehen wird, sobald die Operation abgeschlossen ist. D.h.:

var promise = new Promise(function(resolve) {
    resolve(loadData('/data1'));
});
console.log(promise);

>> Promise { "pending" }

Um das Promise auszuführen,  nutzt man den then-Befehl:

promise.then(function(data) {
    console.log(data);
});

Das sieht ja fast wie ein normales Callback aus! Also wozu das alles? Ganz einfach! Promises haben zwei entscheidende Vorteile:

  • Chaining wird ermöglicht
  • Error Handling wird einfacher

Um nun unsere 3 externen Funktionen zu verketten und gleichzeitig nach Errors zu schauen, schreibt man:

let obj = {};
loadData('/data1').then((data1) => {
    obj['file1'] = data1;
    return(loadData('/data2'));
}).then((data2) => {
    obj['file2'] = data2;
    return(loadData('/data3'));
}).then((data3) => {
    obj['file3'] = data3;
    console.log(obj);
}).catch(function (e) {
    console.err("ERROR!");
});

>> {"file1" => "how", "file2" => "are", "file3" => "you"}

Der Code wird also nicht nur übersichtlicher. Man kann sich auch um alle Errors innerhalb der Promises kümmern.
Aber irgendwie ist auch das nicht zufriedenstellend. Möchte man in diesem Codeschnippsel sequenzielle Operationen (if/else, for/while, …) einbringen, gestaltet sich das als problematisch. Sequenzielle Programmierung erlauben Promises nicht. Also die Frage: Geht es nicht irgendwie besser? – Schauen wir uns einen anderen Ansatz an: Generator Funktionen

Generator Funktionen

Auf dem ersten Blick sehen Generator Funktionen wie jede andere Funktion aus. Jedoch ermöglichen sie Pausen beim Ausführen von Operationen. Eine Funktion könnte wie folgt aussehen:

function* myGenerator() {
    const obj = yield loadData('/data1');
    console.log(obj);
}

Die Funktion zeichnet sich durch das Sternchen zu Beginn und dem Keyword yield aus. Die Funktion geht bis zum yield und wartet, bis das dazugehörige Promise ausgeführt wurde. Erst dann geht es weiter. Das klingt praktisch, aber so einfach ist es leider nicht: Generator Funktionen brauchen Hilfsfunktionen. Diese verarbeiten das Promise und geben der Generator Funktion Bescheid, wenn sie weiterarbeiten darf:

const iterator = myGenerator();
const iteration = iterator.next();

iteration.value.then(resolvedValue => {
    iterator.next(resolvedValue);
});

Die Generator Funktion entpuppt sich als ein Iterator, der über jedes yield einzeln iteriert. Die gewonnen Informationen werden der Generator Funktion returnt. Es zeigt sich, dass Generator Funktionen auch nicht wirklich die Spitze des Eisberges darstellen.

Und genau aus diesem Grund kommen asynchrone Funktionen ins Spiel!

Asynchrone Funktionen

Asynchrone Funktionen in JS kann man sich als Kombination aus Promises und Generator Funktionen vorstellen. Sie verbinden das Chaining und Error Handling der Promises mit der Möglichkeit, innerhalb einer Funktion zu pausieren. Dadurch wird auch Sequenzielle Programmierung wieder möglich! Aber genug der Worte, wie sehen diese „magischen“ Funktionen nun aus?!

asynch function getData(){
    let result = await loadData('/data1');
    console.log(result);
}

Das sieht fast wie eine Generator Funktion ohne Iterator aus, oder? Exakt! Genau das macht die Asynchronen Funktionen aus. Sie brauchen keine Hilfsfunktionen um Promises auszuführen, sondern führen sie alle sequenziell und vollkommen automatisch aus. Wie würde es nun aussehen, wenn wir wieder unsere drei Dateien laden und den Inhalt speichern wollen?

async function test() {
    let obj = {};
    try{
        let data1 = await loadData('/data1');
        let data2 = await loadData('/data2');
        let data3 = await loadData('/data3');

        obj['file1'] = data1;
        obj['file2'] = data2;
        obj['file3'] = data3;

        console.log(obj)
    }catch(e){
        console.err('Error ist aufgetreten: ' + e);
    }
}

>> {"file1" => "how", "file2" => "are", "file3" => "you"}

Ziemlich simpel, oder? Wie man sehen kann, wurde für das Error Handling einfach ein try-catch-Block eingebunden. So, wie man es kennt, ohne irgendwelche Anweisungen kompliziert zu verschachteln.

Mit dieser Struktur kann man programmieren, wie man es kennt: sequenziell und einfach. Auch Schleifen oder If-Bedingungen stellen kein Problem mehr dar.