Votre premier Service Worker : Cachez cet asset que je ne saurais voir

le

Bearstech | Dernière mise à jour : 2019-09-26

Gagnez un plus grand contrôle du cache navigateur pour améliorer l'expérience de vos utilisateurs.

Les service workers, on en parle depuis quelques années. On peut remonter à 2013 si on en croit le W3C et sa première définition par le W3C date de mai 2014. En 2019, la majorité des navigateurs modernes supportent les service workers (sauf Internet Explorer parce qu'il a une réputation à défendre).

En quoi le service worker peut-il améliorer l'expérience des visiteurs ?

Un service worker est un bout de code JavaScript exécuté par votre navigateur en marge du chargement de la page. C'est une technologie entièrement asynchrone qui dépend donc des Promise JavaScript.

Les cas d'utilisation les plus courants sont :

  • Contrôle du cache client. Avec un service worker, vous définissez les ressources qui seront mises en cache (images, pages HTML, fichiers CSS, etc.) et de quelle manière elles seront récupérées par le visiteur. Les éléments mis en cache peuvent être servis par le service worker au lieu de votre serveur. Ainsi, il n'y a même pas de requête effectuée sur le réseau, la ressource est déjà présente sur la machine du visiteur.
  • Envoi de notifications push au client. Vous savez, tous ces sites qui vous demandent s'ils peuvent faire apparaître des notifications intrusives. À utiliser avec parcimonie...
  • Création d'une Progressive Web App avec la possibilité de navigation hors-ligne. Qui n'a pas de coupure d'internet ?

Le cas qui nous intéresse aujourd'hui, c'est le contrôle du cache.

Comment ça marche ?

Pour pouvoir utiliser un service worker, il faut remplir deux conditions préalables :

  • Votre navigateur doit le supporter. Si votre navigateur est à jour, il le supporte. Vous pouvez voir rapidement si c'est le cas ici : https://jakearchibald.github.io/isserviceworkerready/.
  • Vous devez servir vos pages en HTTPS. Mais ça marche aussi sur localhost.

Comme dit précédemment, le service worker tourne en marge de la page web. Il a son propre cycle de vie dont voici une simple illustration.

Cycle de vie d'un Service Worker

Enregistrer le service worker chez le client

Tout d'abord, il faut que l'utilisateur télécharge le service worker.
L'enregistrement d'un service worker se fait via le JavaScript de votre page. Exemple :

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
      // L'enregistrement a fonctionné
      console.log("Enregistrement du service worker réussi pour le périmètre : ", registration.scope);
    }, function(err) {
      // L'enregistrement a échoué :(
      console.log("Échec de l'enregistrement du service worker: ", err);
    });
  });
}

Ce qui se passe ici :

  • On vérifie d'abord que le navigateur supporte les service workers. On n'est pas à l'abri d'un utilisateur qui ne fait pas ses mises à jour, ou pire, qui utilise encore Internet Explorer.
  • Si le navigateur supporte les service workers, on lui demande d'enregistrer le service worker situé à /sw.js.

Petite remarque : le service worker est situé à la racine du site. C'est une pratique courante. En effet, on souhaite que notre service worker puisse traiter toutes les requêtes sur le domaine du site. Pour un site au domaine https://exemple.com, le périmètre (register.scope) est https://exemple.com/. Si le service worker est placé dans /sous-repertoire/sw.js son périmètre serait https://exemple.com/sous-repertoire/ et ne pourrait donc pas traiter les requêtes en dehors de ce périmètre, comme https://exemple.com/static/. On peut néanmoins préciser le périmètre à l'enregistrement du service worker. On aurait pu écrire par exemple :

navigator.serviceWorker.register('/sw.js', { scope: '/sous-repertoire/' }).then(function(registration) {
  // ...
}, function(err){
  // ...
});

Cette option peut-être utile dans le cas où on souhaiterait enregistrer plusieurs service workers sur le même domaine. C'est possible si chaque service worker fonctionne dans un périmètre différent.

Installer le service worker dans le navigateur

Si on suit le schéma du cycle de vie du service worker, on peut voir que la première étape est l'installation. On a accès à différents événements qui correspondent aux étapes du cycle de vie du service worker : install, activate et fetch. Pour le moment, c'est l'événement install qui nous intéresse. Dans notre service worker on pourra donc écrire :

self.addEventListener('install', function(event) {
  // Code exécuté à l'installation du service worker
});

Dans cette étape, on va pouvoir "pre-cacher" des éléments utile. Typiquement, les feuilles de style CSS, les scripts JS et les polices de caractère. Des ressources utiles sur toutes les pages du site.

Exemple :

var CACHE_ASSETS = 'assets-v1';
var assets = [
  '/',
  '/static/main.css',
  '/static/main.js'
];

self.addEventListener('install', function(event) {
  // Installation du service worker
  event.waitUntil(
    // On utillise le cache des assets
    caches.open(CACHE_ASSETS)
      .then(function(cache) {
        // On pre-cache tous nos assets utiles
        return cache.addAll(assets);
      })
  );
});

Dans cet exemple, on définit un cache et une liste d'assets utiles qu'on souhaite stocker, puis on les ajoute en mémoire durant l'événement d'installation.

Activer le service worker

L'étape suivante du cycle de vie est l'activation. Là on va utiliser l'événement activate :

self.addEventListener('activate', function(event) {
  // Code exécuté à l'activation du service worker
});

Cette étape est exécutée à l'activation d'un nouveau service worker. Il peut s'agir d'un nouveau service worker qui viendra remplacer une précédente version. C'est le moment idéal pour invalider d'anciens caches. Il est important de noter que l'événement d'activation n'est déclenché que si l'ancienne version du service worker n'est plus utilisée. D'autres instances de votre navigateur peuvent utiliser une ancienne version du service worker (multiples onglets ouverts).

On est en droit de se demander pourquoi on ne purgerait pas le cache à l'installation du nouveau service worker. Il ne faut pas oublier que le service worker et exécuté en marge de la page web et qu'il est toujours en activité, sur chaque onglet du navigateur qui affiche le site contrôlé par ce service worker. Tant qu'un onglet contenant l'ancien service worker est ouvert, il est toujours actif et peut potentiellement chercher à servir des ressources en cache. Vider le cache d'un service worker encore actif, ça serait comme débarrasser l'assiette de quelqu'un en train de manger pour lui apporter le dessert.

Dans l'exemple précédent, on a pu voir un cache nommé assets-v1. On peut définir plusieurs conteneurs de cache, chacun associé à une clé. Et on y ajoute un numéro de version pour avoir un élément distinctif qui nous permettra plus tard de l'invalider quand on voudra mettre à jour les assets en cache. Typiquement, la prochaine fois qu'on mettra à jour des assets, on renommera CACHE_ASSETS par assets-v2.

Il faut purger les anciens caches, d'une part pour pouvoir mettre à jour ses feuilles de style ou ses scripts, d'autre part pour éviter de consommer de l'espace disque chez l'utilisateur avec du contenu obsolète.

Exemple :

self.addEventListener('activate', function(event) {

  // Définition des clés de conteneurs de cache à jour
  var cacheWhitelist = ['assets-cache-v2', 'other-cache-v2'];

  event.waitUntil(
    // Récupération de tous les conteneurs 
    // de cache existants sur le périmètre
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          // Si le conteneur de cache ne fait 
          // pas partie de la liste à jour, on le purge
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Ici, durant l'événement d'activation, on va définir des clés de cache qui seront valides (qu'on a pu définir durant l'événement d'installation). Puis on va supprimer tous les autres caches présents sur le périmètre.

Traiter les requêtes dans le service worker

Une fois notre service worker installé et activé, il attend qu'on lui donne à manger. Il va se déclencher à chaque fois qu'une requête est faite dans son périmètre via l'événement fetch:

self.addEventListener('fetch', function(event) {
  // Traitement de la requête par le service worker
});

C'est dans cette partie qu'on va définir des stratégie de cache. Quelques exemples de stratégies :

  • Cache first : on récupère la réponse depuis le cache. Si elle n'existe pas dans le cache, on va la chercher sur le serveur et on la met en cache. C'est la stratégie la plus employée pour les fichiers statiques (CSS, JS, Fonts).
  • Network first : on récupère la réponse sur le serveur. Si le serveur ne répond pas, on va chercher la ressource dans le cache si elle existe. Si elle n'existe pas dans le cache... au moins on aura essayé ! C'est la stratégie utilisée pour l'affichage de contenus sujets à changements réguliers : un blog.
  • Stale while revalidate : on récupère la réponse depuis le cache, puis on va la chercher sur le réseau et on la met en cache pour la prochaine visite. Cette stratégie peut-être intéressante pour afficher du contenu sujet à modification mais dont la mise à jour immédiate n'est pas primordiale. Une image d'avatar par exemple.

Exemple d'une stratégie "cache first" pour des assets qui doivent se trouver dans /static :

self.addEventListener('fetch', function(event) {
  const requestUrl = new URL(
    event.request.url
  );

  if (requestUrl.pathname.startsWith("/static")) {
      // On ouvre le cache des assets
      const promiseResponse = caches.open(CACHE_ASSETS)
        .then(function(cache) {
          // On cherche si la requête existe dans le cache
          return cache.match(event.request)
            .then(function(response) {
              if (response) {
                // Si la requête existe dans le cache, 
                // on renvoie la réponse trouvée
                return response;
              } else {
                // Sinon on va chercher la ressource sur le serveur
                return fetch(event.request)
                  .then(function(response) {
                    // Une fois qu'on a reçu la réponse, on met en cache
                    // pour la prochaine fois.

                    // On n'oublie pas de cloner la réponse pour pouvoir
                    // la mettre en cache.

                    // Une réponse ne peut être lue qu'une seule fois, 
                    // d'où le clone.
                    cache.put(
                      event.request,
                      response.clone()
                    );

                    // Et on retourne la réponse
                    return response;
                  });
              }
            });

      });

      // Une fois que la promesse a fini de s'exécuter, on envoie la réponse
      event.respondWith(promiseResponse);
  }
});

Dans cet exemple, on vérifie que la requête demande une ressource située dans /static. Dans ce cas, la ressource demandée est un asset et on applique notre stratégie "cache first".
On va donc chercher dans notre cache des assets CACHE_ASSETS si la requête a déjà été mise en cache.
Si c'est le cas, on la retourne depuis le cache.
Si la requête n'est pas dans le cache, on va chercher la ressource sur le serveur, et on stocke la réponse dans le cache pour la prochaine fois.
À noter que la réponse stockée dans le cache est clonée. En effet, une Reponse est un stream et ne peut donc être lue qu'une fois. Si on ne clone pas la réponse, on aura une erreur comme suit : The FetchEvent for "<URL>" resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request.

Pour résumer, si le service worker trouve la ressource demandée en cache, il intercepte la requête. On ne fait même pas de requête sur le réseau. C'est écologique, et ça allège la charge.

Service Worker Cache First

Et après ?

Après avoir lu cet article, vous avez un service worker fonctionnel. Mais on peut y ajouter d'autres stratégies de cache pour les contenus ou la navigation hors-ligne. Ça pourra faire l'objet d'un futur article sur la construction de PWA (Progressive Web App).

Pour aller plus loin

Sources

Service Audit de Performance web docker

Bearstech vous propose ses services Audit de Performance web docker

Découvrir ce service

Partager cet article

Flux RSS

flux rss

Partager cet article :