Asynch JS – Die Macht von $.Deferred

Jeremy Chone
Jeremy Chone

Einer der wichtigsten Aspekte beim Erstellen flüssiger und responsiver HTML5-Anwendungen ist die Synchronisierung zwischen all den verschiedenen Teilen der Anwendung, wie Datenabruf, Verarbeitung, Animationen und Benutzeroberflächenelemente.

Der Hauptunterschied zu einer Desktop- oder nativen Umgebung besteht darin, dass Browser keinen Zugriff auf das Threading-Modell gewähren und einen einzigen Thread für alles bereitstellen, was auf die Benutzeroberfläche (d.h. das DOM) zugreift. Das bedeutet, dass sich die gesamte Anwendungslogik, die auf die Elemente der Benutzeroberfläche zugreift und diese ändert, immer im selben Thread befindet. Daher ist es wichtig, alle Arbeitseinheiten der Anwendung so klein und effizient wie möglich zu halten und alle asynchronen Funktionen des Browsers so weit wie möglich zu nutzen.

Asynchrone Browser-APIs

Glücklicherweise stellt Browser eine Reihe asynchroner APIs bereit, z. B. die häufig verwendeten XHR-APIs (XMLHttpRequest oder "AJAX") sowie IndexedDB, SQLite, HTML5 Web Worker und die HTML5 GeoLocation APIs, um nur einige zu nennen. Sogar einige DOM-bezogene Aktionen werden asynchron bereitgestellt, z. B. CSS3-Animationen über die „transitEnd“-Ereignisse.

Asynchrone Programmierung durch Browser für die Anwendungslogik erfolgt über Ereignisse oder Callbacks.
Bei ereignisbasierten asynchronen APIs registrieren Entwickler einen Event-Handler für ein bestimmtes Objekt (z.B. ein HTML-Element oder andere DOM-Objekte) und rufen dann die Aktion auf. Der Browser führt die Aktion normalerweise in einem anderen Thread aus und löst das Ereignis gegebenenfalls im Hauptthread aus.

Code mit der XHR API, einer ereignisbasierten asynchronen API, würde beispielsweise wie folgt aussehen:

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

Das CSS3-Ereignis „transitEnd“ ist ein weiteres Beispiel für eine ereignisbasierte asynchrone API.

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

Andere Browser-APIs wie SQLite und die HTML5-Standortbestimmung basieren auf Callbacks. Das bedeutet, dass der Entwickler eine Funktion als Argument übergibt, das von der zugrunde liegenden Implementierung mit der entsprechenden Auflösung aufgerufen wird.

Für die HTML5-Standortbestimmung sieht der Code beispielsweise so aus:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

In diesem Fall rufen wir einfach eine Methode auf und übergeben eine Funktion, die mit dem angeforderten Ergebnis zurückgerufen wird. Dadurch kann der Browser diese Funktion synchron oder asynchron implementieren und dem Entwickler unabhängig von den Implementierungsdetails eine einzige API zur Verfügung stellen.

Anwendungen asynchron machen

Abgesehen von den integrierten asynchronen APIs des Browsers sollten gut strukturierte Anwendungen auch ihre Low-Level-APIs asynchron zur Verfügung stellen, insbesondere bei E/A- oder rechenintensiver Verarbeitung. APIs zum Abrufen von Daten sollten beispielsweise asynchron sein und NICHT so aussehen:

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

Für dieses API-Design muss getData() blockiert werden. Dadurch wird die Benutzeroberfläche eingefroren, bis die Daten abgerufen wurden. Wenn die Daten im JavaScript-Kontext lokal sind, ist dies möglicherweise kein Problem. Wenn die Daten jedoch aus dem Netzwerk oder sogar lokal in einem SQLite- oder Indexspeicher abgerufen werden müssen, kann dies erhebliche Auswirkungen auf die Nutzerfreundlichkeit haben.

Das richtige Design besteht darin, alle Anwendungs-APIs, deren Verarbeitung einige Zeit in Anspruch nehmen könnte, proaktiv von Anfang an asynchron zu machen, da es eine schwierige Aufgabe sein kann, synchronen Anwendungscode in einen asynchronen Anwendungscode umzuwandeln.

Das vereinfachte getData()-API würde beispielsweise so aussehen:

getData(function(data){
alert("We got data: " + data);
});

Das Schöne an diesem Ansatz ist, dass der UI-Code der Anwendung von Anfang an asynchron ausgerichtet wird und die zugrunde liegenden APIs entscheiden können, ob sie später asynchron sein müssen oder nicht.

Hinweis: Nicht alle Anwendungs-APIs benötigen oder sollten asynchron sein. Als Faustregel gilt, dass jede API, die E/A oder hohe Verarbeitungsschritte ausführt (was länger als 15 ms dauern kann), von Anfang an asynchron verfügbar gemacht werden sollte, auch wenn die erste Implementierung synchron ist.

Umgang mit Fehlern

Ein Spezialgebiet bei der asynchronen Programmierung besteht darin, dass die traditionelle versuchte/Catch-Methode zur Fehlerbehandlung nicht mehr wirklich funktioniert, da Fehler normalerweise in einem anderen Thread auftreten. Daher muss der Aufgerufene über eine strukturierte Methode verfügen, um den Aufrufer zu benachrichtigen, wenn während der Verarbeitung ein Fehler auftritt.

In einer ereignisbasierten asynchronen API wird dies häufig dadurch erreicht, dass der Anwendungscode das Ereignis oder Objekt beim Empfang des Ereignisses abfragt. Bei Callback-basierten asynchronen APIs empfiehlt es sich, ein zweites Argument zu verwenden, das eine Funktion annimmt, die bei einem Fehler mit den entsprechenden Fehlerinformationen als Argument aufgerufen wird.

Unser getData-Aufruf würde so aussehen:

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

Zusammenführung mit $.Deferred

Eine Einschränkung des oben beschriebenen Callback-Ansatzes besteht darin, dass es sehr umständlich werden kann, selbst eine mäßig erweiterte Synchronisierungslogik zu schreiben.

Wenn Sie beispielsweise warten müssen, bis zwei asynchrone APIs fertig sind, bevor Sie eine dritte ausführen, kann die Codekomplexität schnell zunehmen.

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

Es kann sogar noch komplexer werden, wenn die Anwendung denselben Aufruf aus mehreren Teilen der Anwendung ausführen muss, da jeder Aufruf diese mehrstufigen Aufrufe ausführen muss oder die Anwendung einen eigenen Caching-Mechanismus implementieren muss.

Glücklicherweise gibt es ein relativ altes Muster namens Promises (ähnlich wie Future in Java) und eine robuste und moderne Implementierung im jQuery-Kern namens $.Deferred, die eine einfache und leistungsstarke Lösung für asynchrone Programmierung bietet.

Der Einfachheit halber definiert das Promise-Muster, dass die asynchrone API ein Promise-Objekt zurückgibt. Dies ist eine Art „Versprechen, dass das Ergebnis mit den entsprechenden Daten aufgelöst wird“. Um die Auflösung zu erhalten, ruft der Aufrufer das Promise-Objekt ab und ruft done(successFunc(data)) auf, der dem Promise-Objekt mitteilt, dass er successFunc aufrufen soll, wenn das „successFunc“-Objekt aufgelöst wird.

Das Beispiel für den getData-Aufruf oben sieht so aus:

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

Hier wird zuerst das dataPromise-Objekt abgerufen und dann die .done-Methode aufgerufen, um eine Funktion zu registrieren, die bei Auflösung der Daten zurückgerufen werden soll. Wir können auch die Methode .fail aufrufen, um den letztendlichen Fehler zu verarbeiten. Sie können so viele .done- oder .fail-Aufrufe haben, da die zugrunde liegende Promise-Implementierung (jQuery-Code) die Registrierung und Callbacks übernimmt.

Mit diesem Muster ist es relativ einfach, erweiterten Synchronisierungscode zu implementieren. jQuery bietet bereits den gebräuchlichsten Code wie $.when.

Der verschachtelte getData/getLocation-Callback oben würde beispielsweise so aussehen:

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

Der Vorteil von jQuery.Deferred ist außerdem, dass es Entwicklern sehr einfach ist, die asynchrone Funktion zu implementieren. Der getData-Wert könnte beispielsweise so aussehen:

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

Wenn also getData() aufgerufen wird, erstellt es zuerst ein neues jQuery.Deferred-Objekt (1) und gibt dann sein Promise (2) zurück, damit der Aufrufer seine erledigten und fehlerhaften Funktionen registrieren kann. Wenn der XHR-Aufruf dann zurückkehrt, wird der verzögerte Aufruf entweder aufgelöst (3.1) oder abgelehnt (3.2). Durch das Ausführen von deferred.resolve werden alle "done"-Funktionen (...) und andere Promise-Funktionen (z. B. Dann und Pipe) ausgelöst, während durch das Aufrufen von deferred.reject alle Fail()-Funktionen aufgerufen werden.

Anwendungsbereiche

Hier sind einige gute Anwendungsfälle, in denen die Funktion „Zurückgestellt“ sehr nützlich sein kann:

Datenzugriff:Es ist oft das Richtige, APIs für den Datenzugriff als $.Deferred verfügbar zu machen. Bei Remote-Daten ist dies offensichtlich, da synchrone Remote-Aufrufe die Nutzerfreundlichkeit vollständig beeinträchtigen würden. Dies gilt jedoch auch für lokale Daten, so oft die untergeordneten APIs (z. B. SQLite und IndexedDB) sind selbst asynchron. Die Funktionen "$.when" und ".pipe" der Deferred API sind extrem leistungsstark, um asynchrone Unterabfragen zu synchronisieren und zu verketten.

UI-Animationen:Das Orchestrieren einer oder mehrerer Animationen mit „transitEnd“-Ereignissen kann recht mühsam sein, insbesondere wenn die Animationen oft eine Mischung aus CSS3-Animationen und JavaScript enthalten. Das Umbrechen der Animationsfunktionen als verzögerte Anwendung kann die Codekomplexität erheblich reduzieren und die Flexibilität verbessern. Selbst eine einfache generische Wrapper-Funktion wie cssAnimation(className), die das Promise-Objekt zurückgibt, das bei „transitEnd“ aufgelöst wird, könnte eine große Hilfe sein.

Anzeige von UI-Komponenten:Dies ist etwas fortgeschrittener, aber erweiterte HTML-Komponenten-Frameworks sollten auch die Funktion „Deferred“ verwenden. Wenn eine Anwendung verschiedene Teile der Benutzeroberfläche anzeigen muss, kann der Lebenszyklus dieser Komponenten in Deferred gekapselt werden, ohne zu sehr ins Detail zu gehen (dies wird der Gegenstand eines anderen Beitrags sein).

Alle asynchronen Browser-APIs:Zu Normalisierungszwecken ist es oft eine gute Idee, die Browser-API-Aufrufe als verzögert zu definieren. Dies erfordert buchstäblich 4 bis 5 Codezeilen, vereinfacht jedoch jeden Anwendungscode erheblich. Wie im obigen Pseudocode „getData/getLocation“ gezeigt, kann der Anwendungscode auf diese Weise über ein einziges asynchrones Modell für alle API-Typen (Browser, Anwendungsdetails und Verbindungen) verfügen.

Caching:Dies ist eine Art Nebenvorteil, kann aber in manchen Fällen sehr nützlich sein. Da die Promise APIs (z.B. .done(...) und .fail(...)) vor oder nach der Ausführung des asynchronen Aufrufs aufgerufen werden können, kann das zurückgestellte Objekt als Caching-Handle für einen asynchronen Aufruf verwendet werden. Zum Beispiel könnte ein CacheManager einfach "Deferred" für bestimmte Anfragen verfolgen und das Promise des übereinstimmenden Deferred zurückgeben, wenn es nicht ungültig gemacht wurde. Das Schöne ist, dass der Aufrufer nicht wissen muss, ob der Aufruf bereits aufgelöst wurde oder gerade aufgelöst wird. Die Callback-Funktion wird genauso aufgerufen.

Fazit

Das Konzept von $.Deferred ist zwar einfach, aber es kann einige Zeit dauern, bis man es richtig verstanden hat. Angesichts der Art der Browserumgebung ist die Beherrschung der asynchronen Programmierung in JavaScript jedoch ein Muss für jeden seriösen HTML5-Anwendungsentwickler. Das Promise-Muster (und die jQuery-Implementierung) sind hervorragende Tools, um die asynchrone Programmierung zuverlässig und leistungsfähig zu machen.