Asynch JS – The power of $.Deferred

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 l'extraction des données, le traitement, les animations et les éléments de l'interface utilisateur.

La principale différence avec un environnement de bureau ou natif est que les navigateurs ne permettent pas d'accéder au modèle de thread et ne fournissent qu'un seul thread pour tout ce qui accède à l'interface utilisateur (c'est-à-dire le DOM). Cela signifie que toute la logique d'application qui accède aux éléments de l'interface utilisateur et les modifie se trouve toujours dans le même thread. Il est donc important de garder toutes les unités de travail de l'application aussi petites et efficaces que possible, et d'exploiter au maximum toutes les fonctionnalités asynchrones offertes par le navigateur.

API asynchrones de navigateur

Heureusement, les navigateurs fournissent un certain nombre d'API asynchrones, telles que les API XHR (XMLHttpRequest ou "AJAX") couramment utilisées, ainsi que les API IndexedDB, SQLite et HTML5, et les API GeoLocation HTML5. 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 d'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 l'action généralement dans un thread différent et déclenche l'événement dans le thread principal, le cas échéant.

Par exemple, le code utilisant l'API XHR, une API asynchrone basée sur les événements, ressemblerait à ceci:

// 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 le rappel, 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 nous suffit d'appeler une méthode et de transmettre une fonction qui sera rappelé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 l'implémentation.

Rendre les applications prêtes à l'emploi

Au-delà des API asynchrones intégrées au navigateur, les applications bien structurées doivent également exposer leurs API de bas niveau de manière asynchrone, en particulier lorsqu'elles effectuent des opérations d'E/S ou des traitements intensifs. 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 fige l'interface utilisateur jusqu'à ce que les données soient extraites. Si les données sont en local dans le contexte JavaScript, cela ne pose peut-être pas de problème. Toutefois, si elles doivent être extraites du réseau ou même localement dans un magasin d'index ou SQLite, cela peut avoir un impact considérable sur l'expérience utilisateur.

La bonne conception consiste à rendre de manière proactive toutes les API d'application dont le traitement peut prendre un certain temps, de manière asynchrone dès le départ, car la réadaptation du code d'application synchrone pour la rendre asynchrone peut s'avérer fastidieuse.

Par exemple, l'API simpliste getData() ressemblera à ceci:

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

L'avantage de cette approche est qu'elle force le code de l'interface utilisateur de l'application à être asynchrone dès le départ et permet aux API sous-jacentes de décider si elles doivent être asynchrones ou non à une étape ultérieure.

Notez que les API d'application n'ont pas toutes besoin ou ne doivent pas nécessairement être asynchrones. La règle est la suivante : toute API qui effectue n'importe quel type d'E/S ou de traitement intensif (toute opération pouvant prendre plus de 15 ms) doit être exposée de manière asynchrone dès le début, même si la première implémentation est synchrone.

Gérer les défaillances

L'un des avantages de la programmation asynchrone est que la méthode traditionnelle "try/catch" de gestion des échecs ne fonctionne plus, car les erreurs se produisent généralement dans un autre thread. Par conséquent, l'appelé doit disposer d'une méthode structurée pour avertir l'appelant en cas de problème lors du traitement.

Dans une API asynchrone basée sur les événements, cela est souvent accompli par le code d'application qui interroge l'événement ou l'objet lors de la réception de l'événement. Pour les API asynchrones basées sur le rappel, il est recommandé d'avoir un deuxième argument qui accepte 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);
});

Synthèse avec $.Deferred

L'une des limites de l'approche de rappel ci-dessus est qu'il peut devenir très fastidieux d'écrire une logique de synchronisation modérément avancée.

Par exemple, si vous devez attendre que deux API asynchrones soient terminées avant d'en utiliser une troisième, la complexité du code peut augmenter rapidement.

// 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 doit effectuer ces appels en plusieurs étapes, ou l'application doit implémenter son propre mécanisme de mise en cache.

Heureusement, il existe un modèle relativement ancien, appelé "Promises" (semblable à Future en Java), ainsi qu'une implémentation robuste et moderne dans jQuery Core appelée $.Deferred, qui fournit une solution simple et puissante pour la programmation asynchrone.

Pour simplifier, le modèle Promises définit que l'API asynchrone renvoie un objet Promise qui est en quelque sorte "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 done(successFunc(data)) qui indiquera à l'objet Promise d'appeler successFunc une fois les "données" résolues.

Ainsi, l'exemple d'appel getData ci-dessus se présente comme suit:

// 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 récupérons d'abord l'objet dataPromise, puis nous appelons la méthode .done pour enregistrer une fonction que nous souhaitons rappeler une fois les données résolues. Nous pouvons également appeler la méthode .fail pour gérer l'échec éventuel. Notez que nous pouvons avoir autant d'appels .done ou .fail que nécessaire, car l'implémentation sous-jacente de Promise (code jQuery) gère l'enregistrement et les rappels.

Avec ce modèle, il est relativement facile d'implémenter du code de synchronisation plus avancé, et jQuery fournit déjà le plus courant, comme $.when.

Par exemple, le rappel getData/getLocation imbriqué ci-dessus ressemblerait à ceci:

// 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);
});  

L'avantage, 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 méthode getData() est appelée, elle crée d'abord un objet jQuery.Deferred (1), puis sa promesse (2) est renvoyée afin que l'appelant puisse enregistrer ses fonctions terminées et défaillantes. Ensuite, lorsque l'appel XHR est renvoyé, il résout le problème différé (3.1) ou le rejette (3.2). L'exécution de "deferred.resolve" déclenchera toutes les fonctions "done(...)" et d'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 la fonction Reporter peut s'avérer très utile:

Accès aux données:il est souvent préférable d'exposer les API d'accès aux données avec le paramètre $.Deferred. C'est évident pour les données distantes, car les appels distants synchrones gâcheraient complètement l'expérience utilisateur, mais cela vaut également pour les données locales, dans la mesure où les API de niveau inférieur (par exemple, SQLite et IndexedDB) sont eux-mêmes asynchrones. Les fichiers $.when et .pipe de l'API Deferred sont extrêmement puissants pour synchroniser et enchaîner des sous-requêtes asynchrones.

Animations d'interface utilisateur:l'orchestration d'une ou plusieurs animations avec des événements transitionEnd peut s'avérer assez fastidieuse, en particulier lorsque les animations sont un mélange d'animations CSS3 et de JavaScript (comme c'est souvent le cas). L'encapsulation des fonctions d'animation en tant que "Deferred" peut considérablement réduire la complexité du code et améliorer la flexibilité. Même une simple fonction wrapper générique telle que cssAnimation(className) qui renverra l'objet Promise qui sera résolu lors de la transitionEnd pourrait vous être d'une grande aide.

Affichage des composants d'interface utilisateur:c'est un peu plus avancé, mais les frameworks de composants HTML avancés doivent également utiliser l'option "Deferred". Sans entrer trop dans les détails (ceci sera l'objet d'un autre article), lorsqu'une application doit afficher différentes parties de l'interface utilisateur, le fait d'avoir le cycle de vie de ces composants encapsulé dans "Différé" permet un plus grand contrôle de la temporalité.

API asynchrone de tout navigateur:à des fins de normalisation, il est souvent judicieux d'encapsuler les appels d'API du navigateur en mode différé. Cela prend littéralement quatre à cinq lignes de code chacune, mais simplifie considérablement le code d'application. Comme indiqué dans le pseudo-code getData/getLocation ci-dessus, le code de l'application peut avoir un modèle asynchrone pour tous les types d'API (navigateurs, spécificités de l'application et complexe).

Mise en cache:il s'agit d'un avantage secondaire, mais qui peut être très utile dans certains cas. Comme 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 suivre les requêtes différées et renvoyer la promesse de la correspondance différée si elle n'a pas été invalidée. L'avantage est que l'appelant n'a pas besoin de savoir si l'appel a déjà été résolu ou s'il est en cours de résolution. Sa fonction de rappel est appelée exactement de la même manière.

Conclusion

Bien que le concept $.Deferred soit simple, il peut prendre du temps de bien le maîtriser. Cependant, compte tenu de la nature de l'environnement du navigateur, il est indispensable de maîtriser la programmation asynchrone en JavaScript pour tout développeur d'applications HTML5 assidu. Le modèle Promise (et la mise en œuvre jQuery) sont des outils incroyables permettant de rendre la programmation asynchrone fiable et performante.