Utiliser les Web Workers pour accélérer vos applications Web mobiles

Article d'origine: 

Ajouter du Javascript multi-tâches à HTML 5: un cocktail gagnant !

Résumé: 

Les applications Web ont depuis longtemps été cantonnées dans un monde mono-tâche. Ceci a réellement limité les développeurs dans leur code, puisque tout ce qui était trop compliqué risquait de geler l'IHM de l'application. Les Web Workers ont changé la donne en apportant le multi-tâches aux applications Web. C'est particulièrement utile pour les applications Web mobiles pour lesquelles la plus grande partie de la logique de l'application se trouve côté client. Dans cet article, vous apprendrez à utiliser les Web Workers et découvrirez les tâches pour lesquels ils sont surtout appropriés. Vous verrez comment les utiliser avec d'autres technologies liées à HTML 5 pour en accroitre l'efficacité.

Pour commencer

Au cours de cet article, vous allez développer des applications web utilisant les technologies les plus récentes. Le plus gros du code est constitué de HTML, de Javascript et de CSS - les technologies de base de tout développeur web. Ce dont vous aurez vraiment besoin, ce sont des navigateurs pour tester tout çà. La majeure partie du code de cet article tournera dans les dernières versions des navigateurs classiques, à quelques exceptions d'importance. Bien sûr, il faut tester sur des navigateurs mobiles également, et vous vous servirez des dernières versions des SDKs iPhone et Android pour ce faire. Pour cet article, les SDKs iPhone 3.1.3 et Android 2.1 ont été utilisés. Les exemples de l'article utilisent également un serveur proxy pour accéder aux services distants depuis le navigateur. Ce proxy est une simple servlet Java™, mais elle peut facilement être remplacée par un proxy en PHP, Ruby ou autres. Voir les Ressources pour les liens.

Javascript multi-tâches sur les appareils mobiles

La programmation multi-tâches ou concurrente n'est absolument pas une nouveauté pour les développeurs. Elle est permise, d'une manière ou d'une autre, par la plupart des langages de programmation modernes. Cependant, Javascript fait partie des exceptions. Ses créateurs pensaient que c'était un peu trop problématique et superflu pour un langage qui a été conçu pour effectuer des tâches simples sur les pages Web. Pourtant, comme les pages Web ont évolué en applications Web, le niveau de complexité des tâches exécutées par Javascript a augmenté au point de le mettre à égalité avec les autres langages. Dans le même temps, les développeurs travaillant avec d'autres langages qui fournissent un support à la programmation concurrente ont souvent souffert à cause de la grande complexité qui accompagne l'utilisation de primitives concurrentes comme les tâches et les mutex. En fait, récemment, un certain nombre de nouveaux langages comme Scala, Clojure et F# sont nés, promettant tous de simplifier la concurrence.

La spécification Web Worker ne se contente pas d'ajouter le support de la concurrence à Javascript et aux navigateurs, elle veut le faire d'une manière intelligente qui ouvrira des possibilités aux développeurs tout en leur épargnant les problèmes liés à ce domaine. Par exemple, les développeurs d'applications classiques utilisent le multi-tâches depuis des années pour accéder à des ressources en lecture/écriture sans geler l'IHM pendant qu'on attend qu'elles soient accessibles. Néanmoins ces applications ont souvent des problèmes quand ces nombreuses tâches modifient des ressources partagées (y compris l'IHM), ce qui conduit à des gels ou même des plantages. Avec les Web Workers, cela ne peut pas arriver. Les tâches supplémentaires n'ont tout simplement pas accès aux mêmes ressources que la tâche principale de l'IHM. En fait, le code de la tâche démarrée ne peut même pas se trouver dans le même fichier que le code exécuté par la tâche principale de l'IHM.

Il faut même fournir ce fichier externe dans le constructeur, comme dans le Listing 1.

Pour atteindre ce résultat trois éléments sont liés:

  1. Le Javascript de la page Web (je l'appelerai le script de page) exécuté dans la tâche principale.
  2. L'objet Worker qui est l'objet Javascript qui sera utilisé pour exécuter la fonction du Web Worker.
  3. Le script qui sera exécuté par la tâche nouvellement créée. Je l'appelerai le script du Worker.

Regardons d'abord le script de page du Listing 1.

Listing 1. Utiliser un Web Worker dans le script de la page

var worker = new Worker("worker.js");
worker.onmessage = function(message){
    // do stuff
};
worker.postMessage(someDataToDoStuffWith);

Dans le Listing 1, on voit les trois grandes étapes dans l'utilisation des Web Workers. Tout d'abord, on crée un objet Worker et on lui passe l'URL du script qui sera exécuté par la nouvelle tâche. La totalité du code qui sera exécuté par le Worker doit se trouver dans le script du Worker, dont on passe l'URL au constructeur du Worker. L'URL du script du Worker est limitée par la règle de la même origine (ndT: Same Origin Policy), il doit provenir du même domaine que le script de page qui crée le Web Worker.

Ensuite on spécifie un gestionnaire de callback par le biais de la fonction onmessage. Ce callback sera invoqué à la fin de l'exécution du script du Worker. message est de la donnée retournée par le script du Worker et qui peut contenir tout ce que l'on veut. Le callback est exécuté par la tâche principale, il a donc accès au DOM. Le script du Worker s'exécute dans une autre tâche et n'a pas accès au DOM, on a donc besoin d'envoyer des données depuis le script du Worker vers la tâche principale, où l'on peut en toute sécurité modifier le DOM pour mettre à jour l'IHM de l'application. C'est le point clé de la conception des Web Workers où rien n'est partagé.

La dernière ligne du Listing 1 montre comment le Worker est initialisé, par un appel à la fonction postMessage. On passe ici un message (encore une fois, il s'agit d'une donnée quelconque) au Worker. Bien sûr, postMessage est une fonction asynchrone: on l'appelle et le retour est immédiat.

Maintenant, examinons le script du Worker. Le code du Listing 2 est le contenu du fichier worker.js du Listing 1.

Listing 2. Un script Worker

importScripts("utils.js");
var workerState = {};
onmessage = function(message){
     workerState = message.data;
      // do stuff with the message
    postMessage({responseXml: this.responseText});
}

On voit que le script du Worker contient une fonction onmessage. Elle est invoquée quand on appelle postMessage dans la tâche principale. Les données passées par le script de la page sont transmises à la fonction postMessage dans l'objet message. On accède à ces données en récupérant la propriété data de cet objet. Quand on a fini de traiter les données dans le script du Worker, on invoque la fonction postMessage pour renvoyer des données à la tâche principale. De la même manière, ces données sont accessibles pour la tâche principale dans la propriété data de l'objet message qu'elle reçoit.

Jusqu'ici, on a vu la sémantique à la fois simple et puissante des Web Workers. Par la suite, nous verrons comment appliquer tout cela pour accélérer les applications Web mobiles. Avant cela, il est nécessaire de parler du support par les matériels. Après tout, nous parlons d'applications Web mobiles et dans ce genre de développement il est essentiel de prendre en compte les capacités propres à chaque navigateur.

Support par les différents matériels

A partir d'Android 2.0, le navigateur supporte totalement la spécification Web Worker de HTML 5. Au moment où cet article est écrit, la plupart des matériels Android sont livrés avec Android 2.1. De plus, cette fonctionnalité est pleinement supportée par le navigateur Mozilla Fennec qui est disponible sur les appareils Nokia avec l'OS Maemo et sur les appareils Windows Mobile. La seule absence notable est donc l'iPhone. Les versions 3.1.3 et 3.2 de l'iPhone OS (les versions qui tournent sur iPad) ne supportent pas encore les Web Workers. Cependant, ils sont déjà supportés par Safari, çà ne devrait donc être qu'une question de temps avant que cela n'arrive dans le navigateur Mobile Safari. Etant donné la prédominance de l'iPhone (surtout aux USA) il vaut mieux ne pas partir du principe que les Web Workers sont disponibles et les utiliser uniquement pour améliorer les applications Web mobiles lorsque leur présence est détectée. Avec cela à l'esprit, voyons comment on peut utiliser les Web Workers pour rendre les applications Web mobiles plus rapides.

Améliorer les performances avec les Workers

Le support des Web Workers par les navigateurs des smartphones est déjà bon et va en s'améliorant. Ce fait amène à se poser cette question: quand faut-il utiliser les Web Workers dans les applications Web mobiles ? La réponse est simple: à chaque fois qu'on doit faire quelque chose qui prend beaucoup de temps. Certains exemples de Workers montrent comment les utiliser pour exécuter des calculs mathématiques onéreux, comme par exemple calculer les 10000 premières décimales de Pi. Il est très peu probable que vous ayez jamais à faire ce genre de calculs dans une application Web, et encore moins dans une application Web mobile. Par contre, récupérer des données de ressources distantes est assez courant et c'est le but de l'exemple de cet article.

Dans cet exemple, nous récupérerons une liste de Daily Deals (des bonnes affaires mises à jour quotidiennement) depuis eBay. Cette liste de bonnes affaires contient une brève description de chaque affaire. Une description plus détaillée peut être obtenue en utilisant l'API eBay's Shopping. Nous utiliserons les Web Workers pour anticiper le chargement de ces informations supplémentaires pendant que l'utilisateur navigue dans la liste des bonnes affaires à la recherche de celle qui l'intéresse. Pour accéder à ces données provenant d'eBay depuis notre application Web, nous devons prendre en compte la règle de la même origine, ce qui nous conduit à utiliser un proxy générique. Une simple servlet Java jouera ce rôle. Elle est incluse dans le code de l'article, mais n'est pas détaillée ici. Nous nous concentrerons plutôt sur le code qui utilise les Web Workers. Le Listing 3 montre la page HTML de base pour l'application.

Listing 3. Code HTML de l'application

<!DOCTYPE HTML>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <meta name = "viewport" content = "width = device-width">
    <title>Worker Deals</title>
    <script type="text/javascript" src="common.js"></script>
  </head>
  <body onload="loadDeals()">
    <h1>Deals</h1>
    <ol id="deals">
    </ol>
    <h2>More Deals</h2>
    <ul id="moreDeals">
    </ul>
  </body>
</html>

Comme vous le constatez, il s'agit de HTML très simple, juste une coquille vide en fait. On récupère les données et on génère l'IHM à l'aide de Javascript. C'est là la conception optimale pour les applications Web mobiles, puisqu'elle permet à tout le code et au balisage statique d'être mis en cache dans l'appareil, et l'utilisateur n'a à attendre que les données du serveur. Notez que, dans le Listing 3, une fois que le corps a été chargé, on appelle la fonction loadDeals (Listing 4) qui effectue le chargement initial des données de l'application.

Listing 4. La fonction loadDeals

var deals = [];
var sections = [];
var dealDetails = {};
var dealsUrl = "http://deals.ebay.com/feeds/xml";
function loadDeals(){
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(){
        if (this.readyState == 4 && this.status == 200){
               var i = 0;
               var j = 0;
               var dealsXml = this.responseXML.firstChild;
               var childNode = {};
               for (i=0; i< dealsXml.childNodes.length;i++){
                   childNode = dealsXml.childNodes.item(i);
                   switch(childNode.localName){
                   case 'Item': 
                       deals.push(parseDeal(childNode));
                       break;
                   case "MoreDeals":
                       for (j=0;j<childNode.childNodes.length;j++){
                           var sectionXml= childNode.childNodes.item(j);
                           if (sectionXml && sectionXml.hasChildNodes()){
                               sections.push(parseSection(sectionXml));
                           }
                       }
                       break;    
                   default:
                       break;
                   }
               }
               deals.forEach(function(deal){
                   var entry = createDealUi(deal);
                   $("deals").appendChild(entry);
               });
               loadDetails(deals);
               sections.forEach(function(section){
                   var ui = createSectionUi(section);
                   $("moreDeals").appendChild(ui);
                   loadDetails(section.deals);
               });
        }
    };
    xhr.open("GET", "proxy?url=" + escape(dealsUrl));
    xhr.send(null);
}

Dans le Listing 4 on voit la fonction loadDeals et les variables globales utilisées dans l'application. On utilise un tableau de deals et un tableau de sections. Il s'agit de groupe de bonnes affaires apparentées (par exemple, Bonnes affaires à moins de 10€). Il y a aussi un tableau associatif appelée dont les clés seront des IDs d'Items (que l'on trouvera dans les données des affaires), et dont les valeurs sont les informations plus détaillées obtenues au moyen de l'API eBay Shopping.

La première chose que l'on fait est un appel Ajax vers un proxy, qui à son tour appelle l'API REST Daily Deals de eBay. Elle fournit la liste des affaires dans un document XML. On analyse ce document dans la fonction onreadystatechange de l'objet XMLHttpRequest utilisé pour faire l'appel Ajax. On utilise deux autres fonctions, parseDeal et parseSection, pour transformer les noeuds XML en objets Javascript d'utilisation plus simple. Vous trouverez le code de ces fonctions dans l'exemple de code téléchargeable (voir les Téléchargements), mais ce sont juste des fonctions d'analyse XML fastidieuses et je ne les ai donc pas incluses ici. Finalement, après avoir analysé le XML, on utilise encore deux autres fonctions, createDealUi et createSectionUi qui modifient le DOM. Quand on a fini, l'IHM  ressemble à la Figure 1.

Figure 1. IHM de l'application

IHM de l'application

Si l'on revient au Listing 4, on note qu'une fois que les affaires sont chargées, on appelle la fonction loadDetails pour chacun d'eux. Dans cette fonction on charge les détails supplémentaires pour chaque affaire, mais uniquement si le navigateur supporte les Web Workers. Le Listing 5 montre la fonction loadDetails.

Listing 5. Préchargement du détail des affaires

function loadDetails(items){
    if (!!window.Worker){
        items.forEach(function(item){
            var xmlStr = null;
            if (window.localStorage){
                xmlStr = localStorage.getItem(item.itemId);
            }
            if (xmlStr){
                var itemDetails = parseFromXml(xmlStr);
                dealDetails[itemDetails.id] = itemDetails;
            } else {
                var worker = new Worker("details.js");
                worker.onmessage = function(message){
                    var responseXmlStr =message.data.responseXml;
                    var itemDetails=parseFromXml(responseXmlStr);
                    if (window.localStorage){
                        localStorage.setItem(
                                        itemDetails.id, responseXmlStr);
                    }
                    dealDetails[itemDetails.id] = itemDetails;
                };
                    worker.postMessage(item.itemId);
            }
        });
    }
}

Dans loadDetails, on vérifie d'abord que la fonction Worker se trouve bien dans le contexte global (l'objet window). Si ce n'est pas le cas, on ne fait rien, tout simplement. Si elle est présente, alors on cherche dans localStorage le XML correspondant au détail de cette affaire. C'est une stratégie de cache local très courante dans les applications Web mobiles, qui est décrite en détail dans la seconde partie de cette série.

Si on trouve ce XML localement, on l'analyse dans la fonction parseFromXml et on ajoute les détails à l'objet dealDetails. Si on ne le trouve pas en local, on génère un Web Worker et on lui passe l'ID d'item de l'affaire au moyen de postMessage. Une fois que le Worker a récupéré les données et rappelle la tâche principale, on analyse le XML, on ajoute le résultat à dealDetails et on stocke le XML dans localStorage. Le listing 6 montre le script du Worker, details.js.

Listing 6. Script du Worker de récupération du détail des affaires

importScripts("common.js");
onmessage = function(message){
    var itemId = message.data;
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(){
        if (this.readyState == 4 && this.status == 200){
            postMessage({responseXml: this.responseText});
        }
    };
    var urlStr = generateUrl(itemId);
    xhr.open("GET", "proxy?url=" + escape(urlStr));
    xhr.send(null);
}

Ce script est assez simple. On utilise Ajax pour faire un appel au proxy, qui appelle à son tour l'API eBay Shopping. Une fois que l'on a reçu le XML du proxy, on le renvoie à la tâche principale au moyen d'un objet Javascript. Remarquez que, bien que l'on puisse utiliser XMLHttpRequest dans un Worker, le résultat se trouvera dans sa propriété responseText, et jamais dans sa propriété responseXml. Ceci est dû au fait qu'il n'y a pas d'analyseur DOM dans la portée du script du Worker. Notez également que la fonction generateUrl vient du fichier common.js (voir le Listing 7). Ce script a été importé au moyen de la fonction importScripts.

Listing 7. Script importé par le Worker

function generateUrl(itemId){
    var appId = "YOUR APP ID GOES HERE";
    return "http://open.api.ebay.com/shopping?callname=GetSingleItem&"+
        "responseencoding=XML&appid=" + appId + "&siteid=0&version=665"
            +"&ItemID=" + itemId;
}

Maintenant que nous avons vu comment retrouver le détail des bonnes affaires (dans les navigateurs qui supportent les Web Workers), revenons à la Figure 1 pour comprendre comment cela est utilisé dans l'application. Remarquez qu'a côté de chaque affaire se trouve un bouton Show Details (ndT: Voir le détail). Cliquez-le pour que l'IHM soit modifiée comme dans la Figure 2.

Figure 1. Affichage du détail de l'affaire

Affichage du détail de l'affaire

Cette IHM est affichée lorsque l'on invque la fonction showDetails. Le Listing 8 donne le code de cette fonction.

Listing 8. La fonction showDetails

function showDetails(id){
    var el = $(id);
    if (el.style.display == "block"){
        el.style.display = "none";
    } else {
        el.style.display = "block";
        if (!el.innerHTML){
            var details = dealDetails[id];
            if (details){
                var ui = createDetailUi(details);
                el.appendChild(ui);
            } else {
                var itemId = id;
                var xhr = new XMLHttpRequest();
                xhr.onreadystatechange = function(){
                    if (this.readyState == 4 && 
                                      this.status == 200){
                        var itemDetails = 
                                        parseFromXml(this.responseText);
                        if (window.localStorage){
                            localStorage.setItem(
                                              itemDetails.id, 
                                              this.responseText);
                        }
                        dealDetails[id] = itemDetails;
                        var ui = createDetailUi(itemDetails);
                        el.appendChild(ui);
                    }
                };
                var urlStr = generateUrl(id);
                xhr.open("GET", "proxy?url=" + escape(urlStr));
                xhr.send(null);                        
            }
        }
    }
}

Cette fonction reçoit l'ID de l'affaire qui va être affichée et on bascule le mode d'affichage. Lors du premier appel, on regarde si les détails ont déjà été stockés dans le tableau associatif dealDetails. Si le navigateur supporte les Web Workers, ces détails ont déjà été récupérés et on crée juste le rendu visuel que l'on ajoute au DOM. Si ces détails n'ont pas encore été chargés, ou si le navigateur ne supporte pas les Web Workers, on fait un appel Ajax pour charger ces données. C'est ce qui fait que l'application marche que les Web Workers soient présents ou non. Autrement dit, si les Workers sont supportés, les données sont déjà chargées et l'IHM répondra instantanément. Sinon, l'IHM les présentera quand même, mais cela prendra quelques secondes.

En résumé

Les Web Workers peuvent sembler une nouvelle technologie assez exotique pour les développeurs Web. Mais, comme vous l'avez vu dans cet article, ils ont des applications très pratiques. C'est tout particulièrement vrai pour les applications Web mobiles. Les Workers peuvent être utilisés pour précharger des données ou exécuter d'autres opérations en avance, ce qui permet d'obtenir une IHM bien plus réactive. C'est encore plus vrai dans des applications Web mobiles qui doivent charger des données à travers un réseau potentiellement lent. Si vous combinez cela avec des stratégies de cache, vos utilisateurs seront impressionnés par la vélocité de votre application.