Données persistantes lors des rechargements de page : cookies, IndexedDB et tout ce qui se trouve entre les deux

Publié: 2022-03-11

Supposons que je visite un site Web. Je clique avec le bouton droit sur l'un des liens de navigation et sélectionne pour ouvrir le lien dans une nouvelle fenêtre. Que devrait-il se passer ? Si je suis comme la plupart des utilisateurs, je m'attends à ce que la nouvelle page ait le même contenu que si j'avais cliqué directement sur le lien. La seule différence devrait être que la page apparaît dans une nouvelle fenêtre. Mais si votre site Web est une application monopage (SPA), vous pouvez voir des résultats étranges à moins que vous n'ayez soigneusement planifié ce cas.

Rappelez-vous que dans un SPA, un lien de navigation typique est souvent un identifiant de fragment, commençant par un signe dièse (#). Cliquer directement sur le lien ne recharge pas la page, donc toutes les données stockées dans les variables JavaScript sont conservées. Mais si j'ouvre le lien dans un nouvel onglet ou une nouvelle fenêtre, le navigateur recharge la page, réinitialisant toutes les variables JavaScript. Ainsi, tous les éléments HTML liés à ces variables s'afficheront différemment, à moins que vous n'ayez pris des mesures pour préserver ces données d'une manière ou d'une autre.

Données persistantes lors des rechargements de page : cookies, IndexedDB et tout ce qui se trouve entre les deux

Données persistantes lors des rechargements de page : cookies, IndexedDB et tout ce qui se trouve entre les deux
Tweeter

Il y a un problème similaire si je recharge explicitement la page, par exemple en appuyant sur F5. Vous pensez peut-être que je ne devrais jamais avoir besoin d'appuyer sur F5, car vous avez mis en place un mécanisme pour envoyer automatiquement les modifications depuis le serveur. Mais si je suis un utilisateur typique, vous pouvez parier que je vais quand même recharger la page. Peut-être que mon navigateur semble avoir repeint l'écran de manière incorrecte, ou je veux simplement être certain d'avoir les toutes dernières cotations boursières.

Les API peuvent être sans état, l'interaction humaine ne l'est pas

Contrairement à une requête interne via une API RESTful, l'interaction d'un utilisateur humain avec un site Web n'est pas sans état. En tant qu'internaute, je considère ma visite sur votre site comme une session, presque comme un appel téléphonique. Je m'attends à ce que le navigateur se souvienne des données de ma session, de la même manière que lorsque j'appelle votre service commercial ou d'assistance, je m'attends à ce que le représentant se souvienne de ce qui a été dit plus tôt dans l'appel.

Un exemple évident de données de session est de savoir si je suis connecté, et si oui, en tant qu'utilisateur. Une fois que j'ai traversé un écran de connexion, je devrais pouvoir naviguer librement dans les pages spécifiques à l'utilisateur du site. Si j'ouvre un lien dans un nouvel onglet ou une nouvelle fenêtre et qu'un autre écran de connexion s'affiche, ce n'est pas très convivial.

Un autre exemple est le contenu du panier d'un site de commerce électronique. Si appuyer sur F5 vide le panier, les utilisateurs risquent de s'énerver.

Dans une application multipage traditionnelle écrite en PHP, les données de session seraient stockées dans le tableau superglobal $_SESSION. Mais dans un SPA, il doit être quelque part du côté client. Il existe quatre options principales pour stocker les données de session dans un SPA :

  • Biscuits
  • Identifiant de fragment
  • espace archivage sur le Web
  • IndexedDB

Quatre kilo-octets de cookies

Les cookies sont une ancienne forme de stockage Web dans le navigateur. Ils étaient à l'origine destinés à stocker les données reçues du serveur dans une requête et à les renvoyer au serveur dans les requêtes suivantes. Mais à partir de JavaScript, vous pouvez utiliser des cookies pour stocker à peu près n'importe quel type de données, jusqu'à une limite de taille de 4 Ko par cookie. AngularJS propose le module ngCookies pour la gestion des cookies. Il existe également un package js-cookies qui fournit des fonctionnalités similaires dans n'importe quel framework.

Gardez à l'esprit que tout cookie que vous créez sera envoyé au serveur à chaque requête, qu'il s'agisse d'un rechargement de page ou d'une requête Ajax. Mais si les données de session principales que vous devez stocker sont le jeton d'accès pour l'utilisateur connecté, vous voulez quand même qu'il soit envoyé au serveur à chaque requête. Il est naturel d'essayer d'utiliser cette transmission automatique des cookies comme moyen standard de spécifier le jeton d'accès pour les requêtes Ajax.

Vous pouvez soutenir que l'utilisation de cookies de cette manière est incompatible avec l'architecture RESTful. Mais dans ce cas, c'est très bien car chaque requête via l'API est toujours sans état, avec des entrées et des sorties. C'est juste que l'une des entrées est envoyée d'une manière amusante, via un cookie. Si vous pouvez faire en sorte que la demande d'API de connexion renvoie également le jeton d'accès dans un cookie, votre code côté client n'a pratiquement pas besoin de gérer les cookies. Encore une fois, il s'agit simplement d'une autre sortie de la requête renvoyée de manière inhabituelle.

Les cookies offrent un avantage par rapport au stockage Web. Vous pouvez cocher la case « Garder ma connexion » sur le formulaire de connexion. Avec la sémantique, je m'attends à ce que si je la laisse décochée, je reste connecté si je recharge la page ou ouvre un lien dans un nouvel onglet ou une nouvelle fenêtre, mais je suis assuré d'être déconnecté une fois que je ferme le navigateur. Il s'agit d'une fonction de sécurité importante si j'utilise un ordinateur partagé. Comme nous le verrons plus tard, le stockage Web ne prend pas en charge ce comportement.

Alors, comment cette approche pourrait-elle fonctionner dans la pratique ? Supposons que vous utilisiez LoopBack côté serveur. Vous avez défini un modèle Personne, en étendant le modèle Utilisateur intégré, en ajoutant les propriétés que vous souhaitez conserver pour chaque utilisateur. Vous avez configuré le modèle Person pour qu'il soit exposé sur REST. Vous devez maintenant modifier server/server.js pour obtenir le comportement de cookie souhaité. Ci-dessous se trouve server/server.js, à partir de ce qui a été généré par le bouclage slc, avec les changements marqués :

 var loopback = require('loopback'); var boot = require('loopback-boot'); var app = module.exports = loopback(); app.start = function() { // start the web server return app.listen(function() { app.emit('started'); var baseUrl = app.get('url').replace(/\/$/, ''); console.log('Web server listening at: %s', baseUrl); if (app.get('loopback-component-explorer')) { var explorerPath = app.get('loopback-component-explorer').mountPath; console.log('Browse your REST API at %s%s', baseUrl, explorerPath); } }); }; // start of first change app.use(loopback.cookieParser('secret')); // end of first change // Bootstrap the application, configure models, datasources and middleware. // Sub-apps like REST API are mounted via boot scripts. boot(app, __dirname, function(err) { if (err) throw err; // start of second change app.remotes().after('Person.login', function (ctx, next) { if (ctx.result.id) { var opts = {signed: true}; if (ctx.req.body.rememberme !== false) { opts.maxAge = 1209600000; } ctx.res.cookie('authorization', ctx.result.id, opts); } next(); }); app.remotes().after('Person.logout', function (ctx, next) { ctx.res.cookie('authorization', ''); next(); }); // end of second change // start the server if `$ node server.js` if (require.main === module) app.start(); });

La première modification configure l'analyseur de cookies pour qu'il utilise "secret" comme secret de signature des cookies, activant ainsi les cookies signés. Vous devez le faire car bien que LoopBack recherche un jeton d'accès dans l'un des cookies 'authorization' ou 'access_token', il nécessite qu'un tel cookie soit signé. En fait, cette exigence est inutile. La signature d'un cookie a pour but de s'assurer que le cookie n'a pas été modifié. Mais vous ne risquez pas de modifier le jeton d'accès. Après tout, vous auriez pu envoyer le jeton d'accès sous une forme non signée, en tant que paramètre ordinaire. Ainsi, vous n'avez pas à vous soucier de la difficulté à deviner le secret de signature des cookies, à moins que vous n'utilisiez des cookies signés pour autre chose.

La deuxième modification configure un post-traitement pour les méthodes Person.login et Person.logout. Pour Person.login , vous souhaitez prendre le jeton d'accès résultant et l'envoyer également au client en tant qu'« autorisation » de cookie signé. Le client peut ajouter une propriété supplémentaire au paramètre d'informations d'identification, Rememberme, indiquant s'il faut rendre le cookie persistant pendant 2 semaines. La valeur par défaut est true. La méthode de connexion elle-même ignorera cette propriété, mais le postprocesseur la vérifiera.

Pour Person.logout , vous souhaitez supprimer ce cookie.

Vous pouvez voir les résultats de ces modifications immédiatement dans l'explorateur d'API StrongLoop. Normalement, après une demande Person.login, vous devez copier le jeton d'accès, le coller dans le formulaire en haut à droite et cliquer sur Définir le jeton d'accès. Mais avec ces changements, vous n'avez rien à faire de tout cela. Le jeton d'accès est automatiquement enregistré en tant que cookie "autorisation", et renvoyé à chaque demande ultérieure. Lorsque l'explorateur affiche les en-têtes de réponse de Person.login, il omet le cookie, car JavaScript n'est jamais autorisé à voir les en-têtes Set-Cookie. Mais rassurez-vous, le cookie est là.

Côté client, lors d'un rechargement de page, vous verriez si le cookie "autorisation" existe. Si tel est le cas, vous devez mettre à jour votre enregistrement de l'ID utilisateur actuel. La façon la plus simple de le faire est probablement de stocker l'ID utilisateur dans un cookie séparé lors d'une connexion réussie, afin que vous puissiez le récupérer lors d'un rechargement de page.

L'identifiant de fragment

Comme je visite un site Web qui a été implémenté en tant que SPA, l'URL dans la barre d'adresse de mon navigateur peut ressembler à "https://example.com/#/my-photos/37". La partie identifiant de fragment de ceci, "#/my-photos/37", est déjà une collection d'informations d'état qui pourraient être considérées comme des données de session. Dans ce cas, je regarde probablement une de mes photos, celle dont l'ID est 37.

Vous pouvez décider d'intégrer d'autres données de session dans l'identifiant de fragment. Rappelez-vous que dans la section précédente, avec le jeton d'accès stocké dans le cookie "autorisation", vous deviez toujours garder une trace de l'ID utilisateur d'une manière ou d'une autre. Une option consiste à le stocker dans un cookie séparé. Mais une autre approche consiste à l'intégrer dans l'identifiant du fragment. Vous pouvez décider que pendant que je suis connecté, toutes les pages que je visite auront un identifiant de fragment commençant par "#/u/XXX", où XXX est l'ID utilisateur. Ainsi, dans l'exemple précédent, l'identifiant de fragment pourrait être "#/u/59/my-photos/37" si mon userId est 59.

Théoriquement, vous pourriez intégrer le jeton d'accès lui-même dans l'identifiant de fragment, évitant ainsi tout besoin de cookies ou de stockage Web. Mais ce serait une mauvaise idée. Mon jeton d'accès serait alors visible dans la barre d'adresse. Toute personne regardant par-dessus mon épaule avec une caméra pouvait prendre un instantané de l'écran, accédant ainsi à mon compte.

Une dernière remarque : il est possible de configurer un SPA de manière à ce qu'il n'utilise pas du tout d'identifiants de fragment. Au lieu de cela, il utilise des URL ordinaires comme "http://example.com/app/dashboard" et "http://example.com/app/my-photos/37", avec le serveur configuré pour renvoyer le HTML de niveau supérieur pour votre SPA en réponse à une demande pour l'une de ces URL. Votre SPA effectue alors son routage en fonction du chemin (par exemple "/app/dashboard" ou "/app/my-photos/37") au lieu de l'identifiant du fragment. Il intercepte les clics sur les liens de navigation et utilise History.pushState() pour pousser la nouvelle URL, puis procède au routage comme d'habitude. Il écoute également les événements popstate pour détecter l'utilisateur qui clique sur le bouton de retour, et procède à nouveau au routage sur l'URL restaurée. Les détails complets sur la façon de mettre en œuvre cela dépassent le cadre de cet article. Mais si vous utilisez cette technique, vous pouvez évidemment stocker les données de session dans le chemin au lieu de l'identifiant du fragment.

Espace archivage sur le Web

Le stockage Web est un mécanisme permettant à JavaScript de stocker des données dans le navigateur. Comme les cookies, le stockage Web est séparé pour chaque origine. Chaque élément stocké a un nom et une valeur, qui sont tous deux des chaînes. Mais le stockage Web est totalement invisible pour le serveur et offre une capacité de stockage bien supérieure à celle des cookies. Il existe deux types de stockage Web : le stockage local et le stockage de session.

Un élément de stockage local est visible dans tous les onglets de toutes les fenêtres et persiste même après la fermeture du navigateur. À cet égard, il se comporte un peu comme un cookie avec une date d'expiration très éloignée dans le futur. Ainsi, il est adapté pour stocker un jeton d'accès dans le cas où l'utilisateur a coché « me garder connecté » sur le formulaire de connexion.

Un élément de stockage de session n'est visible que dans l'onglet où il a été créé, et il disparaît lorsque cet onglet est fermé. Cela rend sa durée de vie très différente de celle de n'importe quel cookie. Rappelez-vous qu'un cookie de session est toujours visible dans tous les onglets de toutes les fenêtres.

Si vous utilisez le SDK AngularJS pour LoopBack, le côté client utilisera automatiquement le stockage Web pour enregistrer à la fois le jeton d'accès et l'ID utilisateur. Cela se produit dans le service LoopBackAuth dans js/services/lb-services.js. Il utilisera le stockage local, à moins que le paramètre RememberMe soit faux (ce qui signifie normalement que la case « Keep Me Connected » n'a pas été cochée), auquel cas il utilisera le stockage de session.

Le résultat est que si je me connecte avec la case "maintenir la connexion" décochée, et que j'ouvre ensuite un lien dans un nouvel onglet ou une nouvelle fenêtre, je ne serai pas connecté là-bas. Très probablement, je verrai l'écran de connexion. Vous pouvez décider vous-même s'il s'agit d'un comportement acceptable. Certains pourraient considérer cela comme une fonctionnalité intéressante, où vous pouvez avoir plusieurs onglets, chacun connecté en tant qu'utilisateur différent. Ou vous pouvez décider que presque personne n'utilise plus d'ordinateurs partagés, vous pouvez donc simplement omettre la case à cocher « Me garder connecté ».

Alors, à quoi ressemblerait la gestion des données de session si vous décidiez d'utiliser le SDK AngularJS pour LoopBack ? Supposons que vous ayez la même situation qu'avant côté serveur : vous avez défini un modèle Person, étendant le modèle User, et vous avez exposé le modèle Person sur REST. Vous n'utiliserez pas de cookies, vous n'aurez donc besoin d'aucune des modifications décrites précédemment.

Côté client, quelque part dans votre contrôleur le plus externe, vous avez probablement une variable comme $scope.currentUserId qui contient l'ID utilisateur de l'utilisateur actuellement connecté, ou null si l'utilisateur n'est pas connecté. Ensuite, pour gérer correctement les rechargements de page, vous incluez simplement cette instruction dans la fonction constructeur de ce contrôleur :

 $scope.currentUserId = Person.getCurrentId();

C'est si facile. Ajoutez 'Person' comme dépendance de votre contrôleur, si ce n'est déjà fait.

IndexedDB

IndexedDB est une nouvelle fonctionnalité permettant de stocker de grandes quantités de données dans le navigateur. Vous pouvez l'utiliser pour stocker des données de n'importe quel type JavaScript, comme un objet ou un tableau, sans avoir à les sérialiser. Toutes les demandes adressées à la base de données sont asynchrones, vous recevez donc un rappel lorsque la demande est terminée.

Vous pouvez utiliser IndexedDB pour stocker des données structurées qui ne sont liées à aucune donnée sur le serveur. Un exemple pourrait être un calendrier, une liste de tâches ou des jeux sauvegardés qui sont joués localement. Dans ce cas, l'application est vraiment locale et votre site Web n'est que le véhicule pour la diffuser.

À l'heure actuelle, Internet Explorer et Safari n'ont qu'un support partiel pour IndexedDB. Les autres principaux navigateurs le prennent entièrement en charge. Une limitation sérieuse pour le moment, cependant, est que Firefox désactive entièrement IndexedDB en mode de navigation privée.

Comme exemple concret d'utilisation d'IndexedDB, prenons l'application de puzzle coulissant de Pavol Daniš, et modifions-la pour enregistrer l'état du premier puzzle, le puzzle coulissant Basic 3x3 basé sur le logo AngularJS, après chaque mouvement. Le rechargement de la page restaurera alors l'état de ce premier puzzle.

J'ai mis en place un fork du référentiel avec ces modifications, qui se trouvent toutes dans app/js/puzzle/slidingPuzzle.js. Comme vous pouvez le constater, même une utilisation rudimentaire d'IndexedDB est assez complexe. Je vais juste montrer les faits saillants ci-dessous. Tout d'abord, la fonction restore est appelée lors du chargement de la page, pour ouvrir la base de données IndexedDB :

 /* * Tries to restore game */ this.restore = function(scope, storekey) { this.storekey = storekey; if (this.db) { this.restore2(scope); } else if (!window.indexedDB) { console.log('SlidingPuzzle: browser does not support indexedDB'); this.shuffle(); } else { var self = this; var request = window.indexedDB.open('SlidingPuzzleDatabase'); request.onerror = function(event) { console.log('SlidingPuzzle: error opening database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onupgradeneeded = function(event) { event.target.result.createObjectStore('SlidingPuzzleStore'); }; request.onsuccess = function(event) { self.db = event.target.result; self.restore2(scope); }; } };

L'événement request.onupgradeneeded gère le cas où la base de données n'existe pas encore. Dans ce cas, nous créons le magasin d'objets.

Une fois la base de données ouverte, la fonction restore2 est appelée, qui recherche un enregistrement avec une clé donnée (qui sera en fait la constante 'Basic' dans ce cas) :

 /* * Tries to restore game, once database has been opened */ this.restore2 = function(scope) { var transaction = this.db.transaction('SlidingPuzzleStore'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var self = this; var request = objectStore.get(this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error reading from database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onsuccess = function(event) { if (!request.result) { console.log('SlidingPuzzle: no saved game for ' + self.storekey); scope.$apply(function() { self.shuffle(); }); } else { scope.$apply(function() { self.grid = request.result; }); } }; }

Si un tel enregistrement existe, sa valeur remplace le tableau de grille du puzzle. S'il y a une erreur dans la restauration du jeu, nous mélangeons simplement les tuiles comme avant. Notez que la grille est un tableau 3x3 d'objets tuiles, chacun étant assez complexe. Le grand avantage d'IndexedDB est que vous pouvez stocker et récupérer ces valeurs sans avoir à les sérialiser.

Nous utilisons $apply pour informer AngularJS que le modèle a été modifié, de sorte que la vue sera mise à jour de manière appropriée. En effet, la mise à jour se produit dans un gestionnaire d'événements DOM, de sorte qu'AngularJS ne serait pas en mesure de détecter le changement. Toute application AngularJS utilisant IndexedDB devra probablement utiliser $apply pour cette raison.

Après toute action qui modifierait le tableau de grille, comme un déplacement de l'utilisateur, la fonction save est appelée, ce qui ajoute ou met à jour l'enregistrement avec la clé appropriée, en fonction de la valeur de grille mise à jour :

 /* * Tries to save game */ this.save = function() { if (!this.db) { return; } var transaction = this.db.transaction('SlidingPuzzleStore', 'readwrite'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var request = objectStore.put(this.grid, this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error writing to database, ' + request.error.name); }; request.onsuccess = function(event) { // successful, no further action needed }; }

Les modifications restantes consistent à appeler les fonctions ci-dessus aux moments appropriés. Vous pouvez consulter le commit montrant toutes les modifications. Notez que nous appelons restauration uniquement pour le puzzle de base, pas pour les trois puzzles avancés. Nous exploitons le fait que les trois puzzles avancés ont un attribut api, donc pour ceux-ci, nous faisons juste le mélange normal.

Et si nous voulions également sauvegarder et restaurer les puzzles avancés ? Cela nécessiterait une restructuration. Dans chacun des puzzles avancés, l'utilisateur peut ajuster le fichier source de l'image et les dimensions du puzzle. Nous devrons donc améliorer la valeur stockée dans IndexedDB pour inclure ces informations. Plus important encore, nous aurions besoin d'un moyen de les mettre à jour à partir d'une restauration. C'est un peu trop pour cet exemple déjà long.

Conclusion

Dans la plupart des cas, le stockage Web est votre meilleur pari pour stocker les données de session. Il est entièrement pris en charge par tous les principaux navigateurs et offre une capacité de stockage bien supérieure à celle des cookies.

Vous utiliserez des cookies si votre serveur est déjà configuré pour les utiliser, ou si vous avez besoin que les données soient accessibles dans tous les onglets de toutes les fenêtres, mais vous voulez également vous assurer qu'elles seront supprimées lorsque le navigateur sera fermé.

Vous utilisez déjà l'identifiant de fragment pour stocker les données de session spécifiques à cette page, telles que l'ID de la photo que l'utilisateur regarde. Bien que vous puissiez intégrer d'autres données de session dans l'identifiant de fragment, cela n'offre pas vraiment d'avantage par rapport au stockage Web ou aux cookies.

L'utilisation d'IndexedDB nécessitera probablement beaucoup plus de codage que n'importe laquelle des autres techniques. Mais si les valeurs que vous stockez sont des objets JavaScript complexes qui seraient difficiles à sérialiser, ou si vous avez besoin d'un modèle transactionnel, cela peut valoir la peine.