L'un des aspects les plus importants de la création d'applications HTML5 fluides et réactives est la synchronisation entre toutes les différentes parties de l'application, telles que la récupération, le traitement, les animations et les éléments d'interface utilisateur des données.
La principale différence avec un ordinateur de bureau ou un environnement natif est que les navigateurs n'autorisent pas l'accès au modèle de threads et fournissent un seul thread pour tout ce qui accède à l'interface utilisateur (c'est-à-dire le DOM). Cela signifie que toute la logique de l'application qui accède et modifie les éléments de l'interface utilisateur se trouve toujours dans le même thread. Il est donc important de conserver toutes les unités de travail de l'application aussi petites et efficaces que possible, et de tirer le meilleur parti des fonctionnalités asynchrones offertes par le navigateur.
API asynchrones du navigateur
Heureusement, les navigateurs fournissent un certain nombre d'API asynchrones, telles que les API XHR (XMLHttpRequest ou "AJAX") couramment utilisées, ainsi que IndexedDB, SQLite, les Web workers HTML5 et les API GeoLocation HTML5, pour n'en citer que quelques-unes. Même certaines actions liées au DOM sont exposées de manière asynchrone, comme l'animation CSS3 via les événements transitionEnd.
Les navigateurs exposent la programmation asynchrone à la logique de l'application via des événements ou des rappels.
Dans les API asynchrones basées sur des événements, les développeurs enregistrent un gestionnaire d'événements pour un objet donné (par exemple, un élément HTML ou d'autres objets DOM), puis appellent l'action. Le navigateur effectue généralement l'action dans un thread différent et déclenche l'événement dans le thread principal si nécessaire.
Par exemple, le code utilisant l'API XHR, une API asynchrone basée sur les événements, se présente comme suit :
// 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();
L'événement transitionEnd CSS3 est un autre exemple d'API asynchrone basée sur des événements.
// 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')
D'autres API de navigateur, telles que SQLite et HTML5 Geolocation, sont basées sur les rappels, ce qui signifie que le développeur transmet une fonction en tant qu'argument qui sera rappelé par l'implémentation sous-jacente avec la résolution correspondante.
Par exemple, pour la géolocalisation HTML5, le code se présente comme suit:
// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){
alert('Lat: ' + position.coords.latitude + ' ' +
'Lon: ' + position.coords.longitude);
});
Dans ce cas, il suffit d'appeler une méthode et de transmettre une fonction qui sera appelée avec le résultat demandé. Cela permet au navigateur d'implémenter cette fonctionnalité de manière synchrone ou asynchrone et de fournir une seule API au développeur, quels que soient les détails de la mise en œuvre.
Préparer les applications à l'exécution asynchrone
En plus des API asynchrones intégrées du navigateur, les applications bien conçues doivent également exposer leurs API de bas niveau de manière asynchrone, en particulier lorsqu'elles effectuent des E/S ou un traitement lourd. Par exemple, les API permettant d'obtenir des données doivent être asynchrones et ne doivent PAS se présenter comme suit :
// WRONG: this will make the UI freeze when getting the data
var data = getData();
alert("We got data: " + data);
Cette conception d'API nécessite que getData() soit bloquant, ce qui gèle l'interface utilisateur jusqu'à ce que les données soient récupérées. Si les données sont locales dans le contexte JavaScript, ce n'est peut-être pas un problème. Toutefois, si les données doivent être extraites du réseau, ou même localement dans un magasin SQLite ou un index store, cela peut avoir un impact considérable sur l'expérience utilisateur.
La bonne conception consiste à rendre proactivement toutes les API d'application dont le traitement peut prendre un certain temps, de manière asynchrone dès le début, car le reconversion d'un code d'application synchrone pour qu'il soit asynchrone peut s'avérer complexe.
Par exemple, l'API getData() simple deviendrait quelque chose comme :
getData(function(data){
alert("We got data: " + data);
});
L'avantage de cette approche est que cela force le code de l'interface utilisateur de l'application à être asynchrone dès le début et permet aux API sous-jacentes de décider si elles doivent être asynchrones ou non par la suite.
Notez que les API de l'application n'ont pas toutes besoin ou ne doivent pas toutes être asynchrones. En règle générale, toute API qui effectue un type d'E/S ou un traitement lourd (tout ce qui peut prendre plus de 15 ms) doit être exposée de manière asynchrone dès le départ, même si la première implémentation est synchrone.
Gérer les échecs
L'un des pièges de la programmation asynchrone est que la méthode traditionnelle try/catch pour gérer les échecs ne fonctionne plus vraiment, car des erreurs se produisent généralement dans un autre thread. Par conséquent, l'appelant doit disposer d'un moyen structuré de signaler à l'appelant un problème lors du traitement.
Dans une API asynchrone basée sur des événements, le code de l'application interroge souvent l'événement ou l'objet lorsqu'il le reçoit. Pour les API asynchrones basées sur des rappels, il est recommandé d'utiliser un deuxième argument qui prend une fonction qui serait appelée en cas d'échec avec les informations d'erreur appropriées comme argument.
Notre appel getData ressemblerait à ceci:
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
Combiner $.Deferred
L'une des limites de l'approche de rappel ci-dessus est qu'il peut s'avérer très fastidieux d'écrire une logique de synchronisation, même modérément avancée.
Par exemple, si vous devez attendre que deux API asynchrones soient terminées avant d'en exécuter une troisième, la complexité du code peut rapidement augmenter.
// 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);
});
Les choses peuvent même devenir plus complexes lorsque l'application doit effectuer le même appel à partir de plusieurs parties de l'application, car chaque appel devra effectuer ces appels en plusieurs étapes, ou l'application devra implémenter son propre mécanisme de mise en cache.
Heureusement, il existe un modèle relativement ancien, appelé "Promises" (un peu semblable à Future en Java) et une implémentation robuste et moderne dans le noyau jQuery appelée $.Deferred, qui fournit une solution simple et efficace à la programmation asynchrone.
Pour simplifier, le modèle Promise définit que l'API asynchrone renvoie un objet Promise qui est une sorte de "promise que le résultat sera résolu avec les données correspondantes". Pour obtenir la résolution, l'appelant obtient l'objet Promise et appelle un élément done(successFunc(data)), qui indiquera à l'objet Promise d'appeler cette méthode successFunc une fois les "données" résolues.
L'exemple d'appel getData ci-dessus devient donc :
// 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);
});
Ici, nous obtenons d'abord l'objet dataPromise, puis nous appelons la méthode .done pour enregistrer une fonction que nous souhaitons appeler lorsque les données sont résolues. Nous pouvons également appeler la méthode .fail pour gérer l'échec éventuel. Notez que nous pouvons effectuer autant d'appels .done ou .fail que nécessaire, car l'implémentation de la promesse sous-jacente (code jQuery) gérera l'enregistrement et les rappels.
Avec ce modèle, il est relativement facile d'implémenter un code de synchronisation plus avancé, et jQuery fournit déjà le plus courant, comme $.when.
Par exemple, le rappel getData/getLocation imbriqué ci-dessus deviendrait quelque chose comme :
// 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);
});
Et le plus beau dans tout cela, c'est que jQuery.Deferred permet aux développeurs d'implémenter très facilement la fonction asynchrone. Par exemple, la requête getData peut se présenter comme suit:
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();
}
Ainsi, lorsque la fonction getData() est appelée, elle crée d'abord un objet jQuery.Deferred (1), puis renvoie sa promesse (2) afin que l'appelant puisse enregistrer ses fonctions terminées et d'échec. Ensuite, lorsque l'appel XHR est renvoyé, il résout l'appel différé (3.1) ou le rejette (3.2). L'exécution de deferred.resolve déclenchera toutes les fonctions done(…) et les autres fonctions de promesse (par exemple, then et pipe), et l'appel de deferred.reject appellera toutes les fonctions fail().
Cas d'utilisation
Voici quelques cas d'utilisation dans lesquels l'option "Différé" peut s'avérer très utile:
Accès aux données:l'exposition des API d'accès aux données en tant que $.Deferred est souvent la bonne conception. Cela est évident pour les données distantes, car les appels distants synchrones ruineraient complètement l'expérience utilisateur, mais cela est également vrai pour les données locales, car les API de bas niveau (par exemple, SQLite et IndexedDB) sont eux-mêmes asynchrones. Les méthodes $.when et .pipe de l'API Deferred sont extrêmement efficaces pour synchroniser et enchaîner des sous-requêtes asynchrones.
Animations de l'interface utilisateur : l'orchestration d'une ou de plusieurs animations avec des événements transitionEnd peut être assez fastidieuse, en particulier lorsque les animations combinent des animations CSS3 et JavaScript (comme c'est souvent le cas). Encapsulant les fonctions d'animation en tant que différé, vous pouvez réduire considérablement la complexité du code et améliorer la flexibilité. Même une fonction de wrapper générique simple comme cssAnimation(className) qui renvoie l'objet Promise résolu sur transitionEnd peut être d'une grande aide.
Affichage du composant d'interface utilisateur : il s'agit d'une fonctionnalité un peu plus avancée, mais les frameworks de composants HTML avancés doivent également utiliser le différé. Sans trop entrer dans les détails (ce sera l'objet d'un autre post), lorsqu'une application doit afficher différentes parties de l'interface utilisateur, le cycle de vie de ces composants encapsulés dans Deferred permet de mieux contrôler le timing.
Toute API asynchrone du navigateur : à des fins de normalisation, il est souvent judicieux d'encapsuler les appels d'API du navigateur en tant que différés. Cela nécessite littéralement quatre à cinq lignes de code chacune, mais cela simplifie grandement le code de l'application. Comme indiqué dans le pseudo-code getData/getLocation ci-dessus, cela permet au code de l'application de disposer d'un modèle asynchrone pour tous les types d'API (navigateurs, spécificités de l'application et composés).
Mise en cache : il s'agit d'un avantage secondaire, mais qui peut s'avérer très utile dans certains cas. En effet, les API Promise (par exemple, .done(…) et .fail(…)) peuvent être appelés avant ou après l'appel asynchrone. L'objet Deferred peut être utilisé comme gestionnaire de mise en cache pour un appel asynchrone. Par exemple, un CacheManager peut simplement garder la trace des Deferred pour des requêtes données et renvoyer la promesse de Deferred correspondant s'il n'est pas invalidé. L'avantage est que l'appelant n'a pas besoin de savoir si l'appel a déjà été résolu ou est en cours de résolution. Sa fonction de rappel sera appelée exactement de la même manière.
Conclusion
Bien que le concept $.Deferred soit simple, il peut prendre du temps à maîtriser. Toutefois, compte tenu de la nature de l'environnement du navigateur, maîtriser la programmation asynchrone en JavaScript est indispensable pour tout développeur d'applications HTML5 sérieux. Le modèle Promise (et l'implémentation jQuery) sont d'excellents outils pour rendre la programmation asynchrone fiable et puissante.