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 la récupération, le traitement, les animations et les éléments d'interface utilisateur des données.

La principale différence avec un environnement de bureau ou 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, nous n'appelons qu'une méthode et transmettons 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 l'implémentation.

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, cela peut ne pas poser de problème. Toutefois, si les données doivent être récupérées sur le réseau ou même localement dans un magasin SQLite ou un index, cela peut avoir un impact considérable sur l'expérience utilisateur.

La conception appropriée consiste à rendre proactivement toutes les API d'application qui peuvent prendre un certain temps à traiter asynchrones dès le départ, car la rétrofittation du code d'application synchrone pour qu'il soit asynchrone peut être une tâche ardue.

Par exemple, l'API getData() simple deviendrait quelque chose comme:

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

L'avantage de cette approche est qu'elle oblige le code de l'interface utilisateur de l'application à être centré sur l'asynchronisme dès le début et permet aux API sous-jacentes de décider si elles doivent être asynchrones ou non à un stade ultérieur.

Notez que toutes les API de l'application ne doivent pas ê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

Un inconvénient de la programmation asynchrone est que la méthode try/catch traditionnelle pour gérer les échecs ne fonctionne plus vraiment, car les 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 se présente comme suit:

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

Mettre tout en place avec $.Deferred

L'approche de rappel ci-dessus présente une limite : il peut devenir très difficile d'écrire une logique de synchronisation même modérément avancée.

Par exemple, si vous devez attendre la fin de deux API asynchrones 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 de promesses définit que l'API asynchrone renvoie un objet Promise, qui est une sorte de "promesse 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 done(successFunc(data)), qui indique à l'objet Promise d'appeler cette fonction de réussite lorsque les "données" sont 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, 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 getData() est appelé, il crée d'abord un nouvel objet jQuery.Deferred (1), puis renvoie sa promesse (2) afin que l'appelant puisse enregistrer ses fonctions "done" et "fail". Ensuite, lorsque l'appel XHR renvoie, il résout le 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 où Deferred peut être très utile:

Accès aux données:il est souvent judicieux d'exposer les API d'accès aux données en tant que $.Deferred. 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 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 simplifiera 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 suivre les différés pour des requêtes données et renvoyer la promesse du différé correspondant s'il n'a pas été 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.