Einer der wichtigsten Aspekte beim Erstellen flüssiger und responsiver HTML5-Anwendungen ist die Synchronisierung aller verschiedenen Teile 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 einzelnen Thread für alle Zugriffe auf die Benutzeroberfläche (d. h. das DOM) bereitstellen. Das bedeutet, dass sich die gesamte Anwendungslogik, die auf die Elemente der Benutzeroberfläche zugreift und sie ä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.
Asynchronous APIs des Browsers
Glücklicherweise bieten Browser eine Reihe von asynchronen APIs wie die gängigen XHR-APIs (XMLHttpRequest oder „AJAX“) sowie IndexedDB, SQLite, HTML5-Webworker und die HTML5-Geolocation-APIs. Sogar einige DOM-bezogene Aktionen werden asynchron bereitgestellt, z. B. CSS3-Animationen über die transitionEnd-Ereignisse.
Browser stellen die asynchrone Programmierung der Anwendungslogik über Ereignisse oder Callbacks zur Verfügung.
In ereignisbasierten asynchronen APIs registrieren Entwickler einen Ereignis-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 so 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 „transitionEnd“ 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 HTML5-Standortbestimmung sind Callback-basiert. Das bedeutet, dass der Entwickler eine Funktion als Argument übergibt, die von der zugrunde liegenden Implementierung mit der entsprechenden Auflösung zurückgerufen wird.
Für die HTML5-Geolokalisierung 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. So 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 für die asynchrone Ausführung vorbereiten
Neben den integrierten asynchronen APIs des Browsers sollten gut strukturierte Anwendungen auch ihre Low-Level-APIs asynchron bereitstellen, insbesondere wenn sie I/O- oder rechenintensive Verarbeitungen ausführen. 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);
Bei diesem API-Design muss getData() blockieren, wodurch die Benutzeroberfläche eingefroren wird, bis die Daten abgerufen wurden. Wenn die Daten im JavaScript-Kontext lokal gespeichert 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 Nutzererfahrung haben.
Das richtige Design besteht darin, alle Anwendungs-APIs, deren Verarbeitung einige Zeit in Anspruch nehmen kann, von Anfang an asynchron zu gestalten. Die Umstellung von synchronem Anwendungscode auf asynchronen Code kann eine entmutigende Aufgabe sein.
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 Code der Anwendungs-UI von Anfang an asynchron orientiert ist und die zugrunde liegenden APIs in einer späteren Phase entscheiden können, ob sie asynchron sein müssen oder nicht.
Beachten Sie, dass nicht alle Anwendungs-APIs asynchron sein müssen oder sein sollten. Als Faustregel gilt, dass jede API, die eine Art von E/A oder eine umfangreiche Verarbeitung durchführt (alles, was länger als 15 ms dauern kann), von Anfang an asynchron verfügbar sein sollte, auch wenn die erste Implementierung synchron ist.
Fehlerbehandlung
Ein Nachteil der asynchronen Programmierung ist, dass die traditionelle Methode „try/catch“ zum Umgang mit Fehlern nicht mehr wirklich funktioniert, da Fehler in der Regel in einem anderen Thread auftreten. Daher muss der Gerufene eine strukturierte Möglichkeit haben, den Anrufer zu benachrichtigen, wenn bei der Verarbeitung etwas schiefgeht.
Bei einer ereignisbasierten asynchronen API erfolgt dies häufig durch den Anwendungscode, der das Ereignis oder Objekt beim Empfang des Ereignisses abfragt. Bei callbackbasierten asynchronen APIs empfiehlt es sich, ein zweites Argument anzugeben, das eine Funktion annimmt, die bei einem Fehler mit den entsprechenden Fehlerinformationen als Argument aufgerufen wird.
Der getData-Aufruf würde dann so aussehen:
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
Zusammenfassung mit $.Deferred
Eine Einschränkung des oben beschriebenen Callback-Ansatzes besteht darin, dass es sehr mühsam werden kann, auch nur mäßig fortgeschrittene Synchronisierungslogik zu schreiben.
Wenn Sie beispielsweise warten müssen, bis zwei asynchrone APIs abgeschlossen sind, bevor Sie eine dritte ausführen, kann die Codekomplexität schnell ansteigen.
// 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);
});
Die Sache kann noch komplizierter 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 die asynchrone Programmierung bietet.
Im Promises-Muster wird einfach definiert, dass die asynchrone API ein Promise-Objekt zurückgibt, das eine Art „Versprechen ist, 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, wodurch das Promise-Objekt aufgefordert wird, diese successFunc aufzurufen, wenn die „Daten“ aufgelöst wurden.
Das Beispiel für den getData-Aufruf oben sieht dann 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 rufen wir zuerst das dataPromise-Objekt ab und dann die Methode .done, um eine Funktion zu registrieren, die aufgerufen werden soll, wenn die Daten aufgelöst wurden. Wir können auch die Methode .fail aufrufen, um den möglichen Fehler zu behandeln. Es können so viele .done- oder .fail-Aufrufe vorliegen, wie wir benötigen, da die Registrierung und die Callbacks von der zugrunde liegenden Promise-Implementierung (jQuery-Code) verarbeitet werden.
Mit diesem Muster lässt sich relativ einfach erweiterter Synchronisierungscode implementieren. jQuery bietet bereits die gängigsten Funktionen wie $.when.
Der verschachtelte getData/getLocation-Callback oben würde beispielsweise in etwa 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);
});
Das Beste daran ist, dass Entwickler mit jQuery.Deferred die asynchrone Funktion ganz einfach implementieren können. Das getData-Objekt 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, wird zuerst ein neues jQuery.Deferred-Objekt (1) erstellt und dann das Promise (2) zurückgegeben, damit der Aufrufer die Funktionen „done“ und „fail“ registrieren kann. Wenn der XHR-Aufruf zurückgegeben wird, wird die Verzögerung entweder aufgelöst (3.1) oder abgelehnt (3.2). Wenn Sie deferred.resolve aufrufen, werden alle done(…)-Funktionen und andere Promise-Funktionen (z. B. then und pipe) ausgelöst. Wenn Sie deferred.reject aufrufen, werden alle fail()-Funktionen aufgerufen.
Anwendungsfälle
Hier sind einige Anwendungsfälle, in denen die verzögerte Ausführung sehr nützlich sein kann:
Datenzugriff: APIs für den Datenzugriff als $.Deferred bereitzustellen, ist oft die richtige Lösung. Das ist bei Remote-Daten offensichtlich, da synchrone Remote-Aufrufe die Nutzerfreundlichkeit völlig ruinieren würden. Das gilt aber auch für lokale Daten, da die APIs der unteren Ebene (z. B. SQLite und IndexedDB) sind selbst asynchron. Die Funktionen $.when und .pipe der Deferred API sind äußerst leistungsfähig, um asynchrone Unterabfragen zu synchronisieren und zu verketten.
UI-Animationen: Das Orchestrating einer oder mehrerer Animationen mit transitionEnd-Ereignissen kann ziemlich mühsam sein, insbesondere wenn die Animationen eine Mischung aus CSS3-Animation und JavaScript sind (was häufig der Fall ist). Das Umschließen der Animationsfunktionen als „Zurückgestellt“ 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 am TransitionEnd aufgelöst wird, könnte eine große Hilfe sein.
UI-Komponentenanzeige:Dies ist etwas fortgeschrittener, aber auch bei erweiterten HTML-Komponenten-Frameworks sollte die verzögerte Ausführung verwendet werden. Ohne zu sehr ins Detail zu gehen (dies wird in einem anderen Beitrag behandelt), wenn eine Anwendung verschiedene Teile der Benutzeroberfläche anzeigen muss, ermöglicht der Lebenszyklus dieser Komponenten in „Deferred“ eine bessere Kontrolle des Timings.
Jede asynchrone Browser-API: Aus Normalisierungsgründen ist es oft sinnvoll, die Browser-API-Aufrufe als verzögert zu verpacken. Das erfordert jeweils nur vier bis fünf Codezeilen, vereinfacht aber den Anwendungscode erheblich. Wie im Pseudocode für getData/getLocation oben gezeigt, kann der Anwendungscode so ein asynchrones Modell für alle API-Typen (Browser, anwendungsspezifisch und zusammengesetzt) haben.
Caching: Dies ist ein eher nebensächlicher Vorteil, kann aber in einigen Fällen sehr nützlich sein. Da die Promise APIs (z. B. .done(…) und .fail(…)) können vor oder nach dem asynchronen Aufruf aufgerufen werden, kann das Deferred-Objekt als Caching-Handle für einen asynchronen Aufruf verwendet werden. Ein CacheManager könnte beispielsweise für bestimmte Anfragen einfach „Deferred“ verfolgen und das Promise of Matching Deferred zurückgeben, wenn es nicht ungültig gemacht wurde. Der Vorteil dabei ist, dass der Aufrufer nicht wissen muss, ob der Vorgang bereits abgeschlossen ist oder noch läuft. Die Rückruffunktion wird genau gleich aufgerufen.
Fazit
Das Konzept von $.Deferred ist zwar einfach, aber es kann einige Zeit dauern, bis Sie es richtig beherrschen. Angesichts der Browserumgebung ist die Bewältigung der asynchronen Programmierung in JavaScript jedoch für jeden seriösen Entwickler von HTML5-Anwendungen unverzichtbar. Das Promise-Muster (und die jQuery-Implementierung) sind hervorragende Tools, die die asynchrone Programmierung zuverlässig und leistungsfähig machen.