Patterns und Best Practices in JavaScript: Der Umgang mit Callback-Funktionen

Tales from the Web side  –  5 Kommentare

Wie in jeder anderen Programmiersprache gibt es auch für JavaScript eine Reihe von Best Practices und damit einhergehenden Bad Practices. Aufgrund seiner dynamischen Eigenschaften hat JavaScript zudem verschiedene Tücken, die Entwickler kennen sollten. Diese neue Artikelreihe soll sich mit all dem beschäftigen: Best Practices, Bad Practices und den Eigenarten der Sprache. Den Anfang machen Callback-Funktionen.

Callback-Funktionen, also Funktionen, die anderen Funktionen als Parameter übergeben und von diesen aufgerufen werden, sind ein oft verwendetes Entwurfsmuster in der asynchronen JavaScript-Entwicklung. Der prinzipielle (noch nicht optimale) Aufbau dieses Entwurfsmusters sieht wie folgt aus:

function doSomething(callback) {
/* ... */
callback();
/* ... */
}

Die Funktion doSomething() erwartet als Parameter eine Funktion und ruft diese zu einem bestimmten Zeitpunkt auf:

function doSomethingElse() {
console.log('Callback aufgerufen');
}
doSomething(doSomethingElse); // Ausgabe: "Callback aufgerufen"
Typ der Callback-Funktion überprüfen

Aufgrund der schwachen Typisierung von JavaScript können einer Funktion, die eine (Callback-)Funktion als Parameter erwartet, prinzipiell auch beliebige andere Werte (oder auch überhaupt kein Wert) übergeben werden. Der Aufruf dieser vermeintlichen Funktion führt dann allerdings zwangsläufig zu einem Typfehler ("callback is not a function"). Auf obiges Beispiel bezogen wären beispielsweise folgende Aufrufe zwar möglich, aber eben nicht ratsam:

doSomething(4711);              // Typfehler, da Zahl
doSomething('Max Mustermann'); // Typfehler, da Zeichenkette
doSomething(); // Typfehler, da kein Parameter

Aus diesem Grund ist es wichtig, den Typ des Callback-Parameters innerhalb der Funktion zuerst zu überprüfen und sicherzustellen, dass es sich wirklich um eine Funktion handelt. Erreicht werden kann dies über den typeof-Operator, wie in folgendem Listing zu sehen. Liefert dieser für den übergebenen Parameter den Wert "function", handelt es sich um eine Funktion, und dem Aufruf steht nichts mehr im Wege:

function doSomething(callback) {
/* ... */
if(typeof callback === 'function') {
callback();
}
/* ... */
}

Gibt es innerhalb einer Funktion mehrere Stellen, an denen die Callback-Funktion aufgerufen werden könnte, müsste man diese Überprüfung jeweils all diesen Stellen voranstellen:

function doSomething(callback) {
/* ... */
if(foo) {
/* ... */
if(typeof callback === 'function') {
callback();
}
} else {
/* ... */
if(typeof callback === 'function') {
callback();
}
}
/* ... */
}

Das lässt sich vermeiden, wenn man wie in folgendem Listing die Überprüfung zu Beginn der Funktion vornimmt und für den Fall, dass es sich beim Callback-Parameter um keine Funktion handelt, diesen einfach mit einer anonymen leeren Funktion neu definiert. Durch diesen einfachen, aber effizienten Trick sichert man alle folgenden Aufrufe der Callback-Funktion auf einen Schlag ab:

function doSomething(callback) {
if(!(typeof callback === 'function')) {
callback = function() {}; // Neudefinition
}
/* ... */
if(foo) {
/* ... */
callback();
} else {
/* ... */
callback();
}
/* ... */
}

In Verbindung mit dem Konditionaloperator lässt sich das Ganze sogar noch auf eine Code-Zeile reduzieren:

callback = (typeof callback === 'function') ? callback : function() {};
Parameter einer Callback-Funktion

Da Callback-Funktionen asynchron aufgerufen werden und weder einen direkten Rückgabewert liefern noch Fehler werfen können, sollten zumindest für diese zwei Fälle entsprechende Parameter vorgesehen werden: einen Parameter, der im Fehlerfall Informationen zu dem aufgetretenen Fehler enthält, sowie einen Parameter, der im Normalfall das Ergebnis der asynchronen Berechnung enthält. Auch wenn prinzipiell die Reihenfolge dieser beiden Parameter egal ist, hat sich als Konvention – insbesondere bei der Entwicklung von Node.js-Modulen – etabliert, als ersten Parameter den Fehler aufzuführen und als zweiten Parameter das Ergebnis (kommt es zu keinem Fehler, ist der erste Parameter entsprechend null).

doSomething(function(
error, // Erster Parameter: Fehlerobjekt
result // Zweiter Parameter: Ergebnis
) {
});
function doSomething(callback) {
/* ... */
var result = null;
try {
// Code, der Fehler produziert
} catch(error) {
callback(error);
}
callback(null, result);
}

Ob ein Fehler aufgetreten ist, lässt sich innerhalb der Callback-Funktion durch eine einfache if-Abfrage herausfinden:

doSomething(function(error, result) {
if(error) {
// Fehlerbehandlung
} else {
// Normalfall
}
});
Rückgabe des Callback-Aufrufs

Wenn man Callback-Funktionen so aufruft, wie in den bisherigen Beispielen gezeigt, kann es in einigen Fällen zu unbeabsichtigtem Programmverhalten kommen. Im vorletzten Listing beispielsweise wird die Callback-Funktion – für den Fall, dass ein Fehler auftritt – zweimal aufgerufen. Hier noch mal der Code:

function doSomething(callback) {
/* ... */
var result = null;
try {
// Code, der Fehler produziert
} catch(error) {
callback(error);
}
callback(null, result);
}

Das Problem hierbei ist, dass der Code nach dem try-catch-Block in jedem Fall aufgerufen wird. Auch, wenn vorher aufgrund eines Fehler bereits in den catch-Block gesprungen wurde und die Callback-Funktion dort aufgerufen wurde. Eine Gegebenheit, die man bei bloßem Lesen des Quelltextes schnell mal übersehen kann. Aus diesem Grund sollte man vor jeden Aufruf einer Callback-Funktion ein "return" stellen, wodurch direkt aus der aufrufenden Funktion herausgesprungen wird.

function doSomething(callback) {
/* ... */
var result = null;
try {
// Code, der Fehler produziert
} catch(error) {
return callback(error, null);
}
return callback(null, result);
}

Voraussetzung hierfür ist natürlich, dass die aufrufende Funktion entsprechend aufgebaut ist und der Aufruf der Callback-Funktion immer auch das Ende der Funktion darstellt. Und nicht wie in folgendem Beispiel gezeigt noch Code danach folgt:

function doSomething(callback) {
/* ... */
var result = null;
try {
// Code, der Fehler produziert
} catch(error) {
return callback(error, null);
}
return callback(null, result);
// Code ab hier wird nie ausgeführt.
if(foo) {
/* ... */
}
}
Ausführungskontext der Callback-Funktion

Besondere Vorsicht ist geboten, wenn man Funktionen als Callback-Parameter übergibt, die per this auf den Ausführungskontext zugreifen. In diesem Fall muss man sich die Funktion bind() zunutze machen und darüber eine neue Funktion erzeugen, die an den gewünschten Ausführungskontext gebunden ist. Anschließend wird diese neue Funktion als Callback-Parameter übergeben:

var person = {
name: 'Max Mustermann',
printName: function() {
console.log(this.name);
}
}
doSomething(person.printName); // Falsch

var printNameBound = person.printName.bind(person);
doSomething(printNameBound); // Richtig
Fazit

Bei der Arbeit mit Callback-Funktion sollte man verschiedene Best Practices berücksichtigen: Hierzu zählen Typüberprüfung, Reihenfolge der Callback-Parameter, einmaliger Aufruf von Callback-Funktionen sowie Festlegen des Ausführungskontextes. Die Grundsatzfrage, ob man statt Callback-Funktionen eher Promises, Generatorfunktionen oder das für ES7 geplante Gespann async/await für die asynchrone Programmierung verwendet, sei an dieser Stelle mal zurückgestellt.

In seinem Buch "Professionell entwickeln mit JavaScript: Design, Patterns und Praxistipps", welches im Rheinwerk Verlag erschienen ist, zeigt der Autor auf 450 Seiten, wie sich JavaScript in der professionellen Softwareentwicklung einsetzen lässt. Die Themen umfassen dabei funktionale und objektorientierte Programmierung, neue Features aus ES6/ES2015, Testen von JavaScript-Anwendungen, Entwicklungsprozess von JavaScript-Anwendungen, Entwurfsmuster, Architekturmuster und vieles andere mehr.