Utiliser le stockage local dans des applications mobiles avec HTML 5

Article d'origine: 

Accélérez vos applications mobiles avec le stockage local standardisé

Résumé: 

Un des apports les plus utiles de HTML 5 est la standardisation du stockage local. Enfin, les développeurs web vont pouvoir arrêter d'essayer de faire tenir toutes les données côté client dans des cookies de 4Ko. Désormais il est possible de stocker de grosses quantités de données sur le client avec une API simple. C'est un mécanisme parfait pour le cache, ce qui permet d'améliorer considérablement la vitesse de l'application — un facteur critique pour les applications web mobiles qui dépendent de connexions beaucoup plus lentes que leurs équivalents de bureau. Dans le second article de cette série sur HTML 5, vous verrez comment utiliser le stockage local, comment le débugger, et un florilège de façons de l'utiliser pour améliorer les applications web mobiles.

Prérequis

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 pour 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. Voir les Ressources pour les liens.

ABC du stockage local

Les développeurs web se battent depuis des lustres avec le stockage de données sur le client. Les cookie HTTP ont sans doute été utilisés de manière abusive pour cela. Les développeurs ont entassé une somme de données incroyable dans les 4Ko permis par la spécification HTTP. La raison en est simple. Les applications web interactives ont besoin de stocker des données pour différentes raisons, et il est souvent inefficace, peu sûr ou inapproprié de stocker ces données sur un serveur. Au fil des ans, on a proposé plusieurs solutions à ce problème. Certains navigateurs ont introduit des APIs de stockage propriétaires. Des développeurs ont fait un usage exhaustif des capacités de stockage du Flash Player en les exposant à travers Javascript. Comme de bien entendu, des librairies Javascript ont essayer de lisser ces différences. En d'autres termes, ces librairies fournissent une API simple, et puis regardent quelles fonctions de stockage existent (que ce soit une API propriétaire du navigateur ou un plugin comme Flash).

Heureusement pour les développeurs web, la spécification HTML 5 contient enfin un standard pour le stockage local qui est implémenté par une large palette de navigateurs. En fait, ce standard a été un de ceux qui ont été le plus vite adoptés et est supporté par les dernières versions de tous les navigateurs majeurs: Microsoft® Internet Explorer®, Mozilla Firefox, Opera, Apple Safari, et Google Chrome. Plus important encore pour les développeurs d'applications mobiles, ce standard est supporté par tous les navigateurs basés sur WebKit comme ceux que l'on trouve sur iPhone ou les téléphones Android (à partir de la version 2.0), ainsi que dans d'autres navigateurs mobiles comme Mozilla Fennec. Cela étant posé, jetons un coup d'oeil à l'API.

Les APIs Storage

L'API localStorage est assez simple. En fait, dans la spécification HTML 5, elle implémente l'interface Storage du DOM. La raison de cette distinction est que HTML 5 spécifie deux objets distincts qui implémentent cette interface, localStorage et sessionStorage. L'objet sessionStorage est une implémentation de Storage qui stocke les données pendant la session uniquement. Plus précisément, dès que plus aucun script actif ne peut accéder à sessionStorage, le navigateur peut l'effacer à sa guise. Cela contraste avec localStorage, qui englobe plusieurs sessions utilisateur. Les deux objets partagent la même API, je me concentrerai donc uniquement sur localStorage.

L'API Storage manipule une structure de données classique de type clé / valeur. Les méthodes les plus couramment utilisées sont getItem(name) et setItem(name, value). Elles fonctionnent exactement comme vous vous y attendez: getItem retourne une valeur associée à ce nom ou null si rien n'existe, tandis que setItem ajoute une paire clé / valeur à localStorage ou remplace une valeur existante. Pour compléter cela, la méthode key(index) retourne le nom associé à la n-ième clé du stockage.

Avec ces APIs très simples, il est possible de faire beaucoup de choses déjà. Entre autres exemples, personnaliser un site ou tracer le comportement de l'utilisateur. Ce sont des applications importantes pour les développeurs web, mais il en existe une encore plus significative: le cache. Avec localStorage, vous pouvez facilement mettre en cache sur la machine du client des données provenant des serveurs. Ceci permet d'éviter d'attendre la réponse à des appels serveur potentiellement longs et minimise la somme de donnée fournies par les serveurs. Il est temps de prendre un exemple qui montre comment utiliser localStorage pour mettre en oeuvre ce genre de cache.

Exemple: création d'un cache avec localStorage

Cet exemple continue dans la lancée de l'exemple que vous avez commencé à développer dans la première partie de cette série. Il montrait comment effectuer une recherche Twitter en récupérant la position de l'utilisateur avec les APIs de géolocalisation. Partons de cet exemple, simplifions-le et améliorons ses performances. Pour commencer, retirons de l'exemple la géolocalisation pour faire une recherche Twitter simple. Le Listing 1 montre le résultat.

Listing 1. Recherche Twitter de base

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name = "viewport" content = "width = device-width"/>
<title>Basic Twitter Search</title>
<script type="text/javascript">
    function searchTwitter(){
        var query = "http://search.twitter.com/search.json?callback
=showResults&q=";
        query += $("kwBox").value;
        var script = document.createElement("script");
        script.src = query;
        document.getElementsByTagName("head")[0].appendChild(script);
    }
    // ui code deleted for brevity
    function showResults(response){
        var tweets = response.results;
        tweets.forEach(function(tweet){
            tweet.linkUrl = "http://twitter.com/" + tweet.from_user 
+ "/status/" + tweet.id;
        });
        makeResultsTable(tweets);
    }
</script>
<!--  CSS enlevée pour raccourcir le code -->
</head>
<body>
    <div id="main">
        <label for="kwBox">Search Twitter:</label>
        <input type="text" id="kwBox"/>
        <input type="button" value="Go!" onclick="searchTwitter()"/>
    </div>
    <div id="results">
    </div>
</body>
</html>

Dans cette application, on utilise le support de JSONP par l'API de recherche Twitter. Quand l'utilisateur veut procéder à une recherche, un appel à l'API est fait par l'ajout dynamique d'un tag script à la page et en spécifiant le nom d'un callback. Ceci permet de faire un appel à un autre domaine dans une page web. Au retour de l'appel, le callback (showResults) est invoqué. On ajoute un lien vers chaque tweet retourné puis on crée une table pour afficher les tweets. Pour accélérer cela, on peut mettre en cache les résultats d'une recherche et les réutiliser quand l'utilisateur soumet une recherche. Pour commencer, regardons comment utiliser localStorage pour stocker localement les tweets.

Sauvegarde locale

La recherche Twitter de base fournit un tableau de tweets retourné par l'API de recherche. Si l'on sauve ces tweets sur le poste local avec le mot-clé qui les a générés, alors on aura un cache utilisable. Pour sauver les tweets, il suffit de modifier le callback qui est invoquée au retour de l'appel à l'API de recherche Twitter. Le Listing 2 montre les fonctions modifiées.

Listing 1. Recherche et sauvegarde

function searchTwitter(){
    var keyword = $("kwBox").value;
    var query = "http://search.twitter.com/search.json?callback
=processResults&q=";
    query += keyword;
    var script = document.createElement("script");
    script.src = query;
    document.getElementsByTagName("head")[0].appendChild(script);
}
function processResults(response){
    var keyword = $("kwBox").value;
    var tweets = response.results;
    tweets.forEach(function(tweet){
        saveTweet(keyword, tweet);
        tweet.linkUrl = "http://twitter.com/" + tweet.from_user + "/status/" + tweet.id;
    });
    makeResultsTable();
    addTweetsToResultsTable(tweets);
}
function saveTweet(keyword, tweet){
    // teste si le navigateur supporte localStorage
    if (!window.localStorage){
        return;
    }
    if (!localStorage.getItem("tweet" + tweet.id)){
        localStorage.setItem("tweet" + tweet.id, JSON.stringify(tweet));
    }
    var index = localStorage.getItem("index::" + keyword);
    if (index){
        index = JSON.parse(index);
    } else {
        index = [];
    }
    if (!index.contains(tweet.id)){
        index.push(tweet.id);
        localStorage.setItem("index::"+keyword, JSON.stringify(index));
    } 
}

Commençons par la première fonction, searchTwitter. Elle est appelée quand l'utilisateur lance une recherche. La seule chose qui a changé par rapport au Listing 1 est le callback. Au lieu de simplement afficher les tweets retournés, il faut leur appliquer un traitement (les enregistrer et les afficher). On spécifie donc un nouveau callback, processResults. On prend chaque tweet et on appelle saveTweet. On passe également le mot-clé qui a servi à générer le résultats de la recherche, parce que l'on veut associer ces tweets à ce mot-clé.

Dans la fonction saveTweet, on commence par s'assurer que le navigateur supporte le stockage local. Comme nous l'avons vu précédemment, c'est une fonctionnalité couramment supportée à la fois par les navigateurs classiques et mobiles, mais c'est toujours une bonne idée de faire un test avant d'utiliser une de ces nouvelles fonctionnalités. S'il n'y a pas de support, on quitte simplement la fonction. Evidemment rien ne sera enregistré, mais il n'y aura pas d'erreur, simplement l'application ne disposera pas d'un cache dans ce cas. Si localStorage est supporté, on regarde d'abord si le tweet a déjà été stocké. Si ce n'est pas le cas, on le stocke localement en utilisant setItem. Ensuite, on récupère l'entrée d'index qui correspond au mot-clé. Il s'agit simplement d'un tableau des IDs des tweets associés au mot-clé. Si l'ID du tweet n'est pas déjà dans l'index, on l'ajoute et on met à jour l'index.

Notez que quand on enregistre et qu'on charge du JSON dans le Listing 3, on utilise JSON.stringify et JSON.parse. L'objet JSON (ou plus exactement window.JSON) est défini dans la spécification HTML5 comme un objet natif qui est toujours présent. Sa méthode stringify transforme un objet Javascript en une chaine sérialisée, tandis que sa méthode parse fait l'inverse: elle crée un objet Javascript à partir de sa représentation sous forme de chaine sérialisée. Ces manipulations sont nécessaires car localStorage ne stocke que des chaines de caractères. Cependant, l'objet JSON natif n'est pas aussi largement implémenté que localStorage. Par exemple, il n'est pas présent dans les derniers navigateurs Safari Mobile sur iPhone (version 3.1.3 quand l'article a été écrit), mais est supporté par les derniers navigateurs Android. Il est facile de vérifier sa présence et le cas échéant charger un fichier Javascript supplémentaire. On peut obtenir le même objet JSON que celui qui est utilisé nativement en allant sur le site json.org (voir les Ressources). Pour voir à quoi ressemblent les chaines sérialisées stockées localement, on peut utiliser un des outils qui permettent d'inspecter ce qui est stocké localement pour un site donné. La Figure 1 montre quelques tweets mis en cache localement et affichés par les outils Chrome pour développeurs.

Figure 1. Tweets mis en cache local

Tweets mis en cache local

Chrome et Safari disposent tous les deux d'outils intégrés qui permettent de voir les données enregistrées dans localStorage. Ce peut être très utile pour débugger les applications qui l'utilisent. Ces outils affichent les paires clé/valeur stockées localement sous forme de texte. Maintenant que nous avons commencé à enregistrer les tweets provenant de l'API de recherche de Twitter pour nous servir de cache, nous devons commencer à les lire depuis localStorage. Regardons dans la suite comment faire.

Chargement rapide des données

Dans le Listing 2, nous avons vu quelques exemples de lecture depuis localStorage en utilisant sa méthode getItem. Désormais quand un utilisateur lance une recherche, on peut tester si on a une correspondance dans le cache et immédiatement retourner les résultats mis en cache si c'est le cas. Bien sûr, il faudra toujours faire requêter l'API de recherche Twitter, puisque les gens tweetent en permanence et ajoutent des résultats aux recherches. Cependant, nous disposons maintenant d'un moyen de requêter plus efficacement en demandant uniquement les résultats que nous n'avons pas en cache. Le Listing 3 montre le code de recherche mis à jour.

Listing 3. Recherche  d'abord en local

function searchTwitter(){
    if ($("resultsTable")){
        $("resultsTable").innerHTML = ""; // clear results
    }
    makeResultsTable();
    var keyword = $("kwBox").value;
    var maxId = loadLocal(keyword);
    var query = "http://search.twitter.com/search.json?callback=processResults&q=";
    query += keyword;
    if (maxId){
        query += "&since_id=" + maxId;
    }
    var script = document.createElement("script");
    script.src = query;
    document.getElementsByTagName("head")[0].appendChild(script);
}
function loadLocal(keyword){
    if (!window.localStorage){
        return;
    }
    var index = localStorage.getItem("index::" + keyword);
    var tweets = [];
    var i = 0;
    var tweet = {};
    if (index){
        index = JSON.parse(index);
        for (i=0;i<index.length;i++){
            tweet = localStorage.getItem("tweet"+index[i]);
            if (tweet){
                tweet = JSON.parse(tweet);
                tweets.push(tweet);
            }
        }
    }
    if (tweets.length < 1){
        return 0;
    }
    tweets.sort(function(a,b){
        return a.id > b.id;
    });
    addTweetsToResultsTable(tweets);
    return tweets[0].id;
}

La première chose que l'on remarque, c'est que quand une recherche est lancée, on appelle d'abord la nouvelle fonction loadLocal. Cette fonction retourne un entier qui est l'ID du tweet le plus récent trouvé dans le cache. La fonction loadLocal prend en entrée un mot-clé qui est utilisé pour trouver les tweets correspondants dans le cache localStorage. Si l'on a un maxId, on l'utilise pour modifier la requête Twitter en ajoutant le paramètre since_id. Cette utilisation de l'API Twitter permet de ne retourner que les tweets qui sont plus récents que celui dont l'ID est passé en paramètre. Cela peut potentiellement réduire le nombre de résultats retournés par Twitter. A chaque fois qu'il est possible optimiser les appels serveur dans une application web mobile, cela peut vraiment améliorer l'expérience utilisateur sur des réseaux un peu lents. Regardons plus en détail ce que fait loadLocal.

Dans la fonction loadLocal, on utilise les données stockées au Listing 2. On récupère d'abord l'index associé au mot-clé en utilisant la méthode getItem. S'il n'existe pas d'index pour ce mot-clé, c'est qu'il n'y a pas de tweets en cache non plus, il n'y a donc aucune optimisation possible sur la requête (et on retourne 0 pour l'indiquer). Si un index est trouvé, on s'en sert pour récupérer une liste d'IDs. Tous ces tweets sont stockés en local, il n'y a donc qu'à utiliser la méthode getItem une fois de plus pour les charger depuis le cache. Les tweets ainsi chargés sont ensuite triés. On utilise la fonction addTweetsToResultsTable pour afficher les tweets et on retourne l'ID du tweet  le plus récent. Dans cet exemple, le code qui récupère les nouveaux tweets appelle directement les fonctions de mise à jour de l'IHM. Ceci vous hérisse peut-être car cette façon de faire couple le code permettant de stocker et de rechercher les tweets avec le code permettant de les afficher, et tout cela se retrouve dans la fonction processResults. L'utilisation d'événements liés au stockage fournit une alternative aboutissant à un couplage moindre.

Evénements liés au stockage

Nous allons maintenant ajouter à l'application la possibilité d'afficher les 10 termes de recherche ayant le plus de résultats en cache. Ce pourrait être une indication des recherches les plus fréquemment soumises par l'utilisateur. Le Listing 4 montre la fonction qui calcule ce top 10 et l'affiche.

Listing 4. Calcul des 10 recherches les plus fréquentes

function displayStats(){
    if (!window.localStorage){ return; }
    var i = 0;
    var key = "";
    var index = [];
    var cachedSearches = [];
    for (i=0;i<localStorage.length;i++){
        key = localStorage.key(i);
        if (key.indexOf("index::") == 0){
            index = JSON.parse(localStorage.getItem(key));
            cachedSearches.push (
                {keyword: key.slice(7), numResults: index.length});
        }
    }
    cachedSearches.sort(function(a,b){
        if (a.numResults == b.numResults){
            if (a.keyword.toLowerCase() < b.keyword.toLowerCase()){
                return -1;
            } else if (a.keyword.toLowerCase() > b.keyword.toLowerCase()){
                return 1;
            }
            return 0;
        }
        return b.numResults - a.numResults;
    }).slice(0,10).forEach(function(search){
        var li = document.createElement("li");
        var txt = document.createTextNode(
            search.keyword + " : " + search.numResults);
        li.appendChild(txt);
        $("stats").appendChild(li);
    });
}

Cette fonction utilise intensivement l'API de localStorage. On commence par retrouver le nombre total de clés stockées dans localStorage et on itère dessus. Si la clé correspond à un index, alors on analyse la valeur associée et on crée un objet qui représente la donnée qui va être traitée: le mot-clé et le nombre de tweets associés à l'index. Cette donnée est stockée dans un tableau nommé cachedSearches. Après cela on trie cachedSearches, d'abord sur le nombre de résultats puis, en cas d'égalité, en faisant un tri alphabétique sans souci de la casse. Enfin on prend les 10 premières recherches, on crée le code HTML pour chaque recherche et on l'ajoute à une liste ordonnée. Appelons cette fonction quand la page est chargée pour la première fois, comme dans le Listing 5.

Listing 5. Initialisation de la page

window.onload = function() {
    displayStats();
    document.body.setAttribute("onstorage", "handleOnStorage();");
}

La première ligne appelle la fonction du Listing 4 lors du chargement de la page. Dans la seconde, les choses deviennent plus intéressantes. On y crée un gestionnaire pour l'événement onstorage. Cet événement est déclenché à chaque fois qu'un appel à la fonction localStorage.setItem se termine. Ceci permet de recalculer le top 10 des recherches. Le Listing 6 montre ce gestionnaire d'événements.

Listing 6. Gestionnaire d'événement de stockage

function handleOnStorage() {
    if (window.event && window.event.key.indexOf("index::") == 0){
        $("stats").innerHTML = "";
        displayStats();
    }
}

L'événement onstorage est associé à la fenêtre. Il a plusieurs propriétés utiles: key, oldValue, et newValue. En sus de ces propriétés qui s'expliquent d'elles-mêmes, il y a également une propriété url (l'URL de la page qui a changé la valeur) et une propriété source (la fenêtre qui contient le script ayant chnagé la valeur). Ces deux dernières propriétés se révèlent utiles dans le cas où l'utilisateur a plusieurs fenêtres, onglets ou même des iFrames correspondant à l'application, ce qui n'est pas très courant pour des applications mobiles. Si nous revenons au Listing 6, la seule propriété dont on a réellement besoin est la propriété key. On l'utilise pour voir si c'est un index qui a été modifié. Si c'est le cas, on réinitialise le top 10 et on le redessine en appelant à nouveau la fonction displayStats. L'avantage de cette technique est qu'aucune autre fonction n' a connaitre l'existence de ce top 10, puisque tout est fait dans displayStats.

J'ai dit plus tôt que l'API Storage du DOM en général, qui comprend à la fois localStorage et sessionStorage, est une fonctionnalité de HTML 5 qui a été largement adoptée. Cependant, les événements liés au stockage font exception — en tous cas concernant les navigateurs classiques. Au moment où cet article a été écrit, les seuls navigateurs classiques supportant les événements liés au stockage sont Safari 4+ et Internet Explorer 8+. Ils ne sont pas supportés par Firefox, Chrome, ou Opera. Néanmoins dans l'univers mobile les choses vont un peu mieux. Les dernières versions des navigateurs sur iPhone et Android supportent complètement ces événements, et le code présenté ici y tourne parfaitement.

En résumé

En tant que développeur, on se sent libéré quand on dispose soudainement d'un énorme espace de stockage côté client. Pour les développeurs web de longue date, cela permet de faire des choses que l'on souhaitait faire depuis un moment, mais pour lesquelles il n'existait pas de bonne solution, n'exigeant pas d'astuces douteuses. Pour les développeurs mobiles, c'est encore plus excitant dans la mesure ou cela permet de mettre en cache local des données. En plus d'améliorer de manière frappante les performances de l'application, le cache local est la clé permettant d'accéder à une autre nouvelle possibilité excitante des applications web mobiles: le mode hors-ligne. Ce sera le sujet du prochain article de cette série.