Livre de recettes hors connexion

Jake Archibald
Jake Archibald

Avec Service Worker, nous avons cessé d'essayer de résoudre les problèmes hors connexion et avons donné aux développeurs les moyens de le résoudre eux-mêmes. Il vous permet de contrôler la mise en cache et la manière dont les requêtes sont traitées. Vous pouvez donc créer vos propres modèles. Examinons quelques modèles possibles de manière isolée, mais en pratique, vous en utiliserez probablement plusieurs en même temps, en fonction de l'URL et du contexte.

Pour une démonstration pratique de certains de ces modèles, consultez Trained-to-thrill et cette vidéo montrant l'impact sur les performances.

La machine cache : quand stocker les ressources

Le service worker vous permet de gérer les requêtes indépendamment du cache. Je vais donc les présenter séparément. Tout d'abord, la mise en cache. Quand doit-elle être faite ?

Lors de l'installation, en tant que dépendance

Lors de l'installation, en tant que dépendance.
À l'installation, en tant que dépendance.

Un service worker renvoie un événement install. Vous pouvez l'utiliser pour préparer des éléments qui doivent être prêts avant de gérer d'autres événements. Pendant ce processus, toute version précédente de votre service worker continue de s'exécuter et de diffuser des pages. Les actions que vous effectuez ici ne doivent donc pas perturber ce processus.

Idéal pour les fichiers CSS, les images, les polices, le code JS, les modèles... Tout ce que vous considérez comme statique dans cette "version" de votre site.

Il s'agit d'éléments qui rendraient votre site entièrement inutilisable s'ils ne pouvaient pas être récupérés, des éléments qu'une application équivalente spécifique à la plate-forme inclurait dans le téléchargement initial.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil prend une promesse pour définir la durée et la réussite de l'installation. Si la promesse est rejetée, l'installation est considérée comme un échec et ce service worker sera abandonné (si une ancienne version est en cours d'exécution, elle restera intacte). caches.open() et cache.addAll() renvoient des promesses. Si l'une des ressources ne peut pas être récupérée, l'appel cache.addAll() est refusé.

Sur trained-to-thrill, je l'utilise pour mettre en cache des éléments statiques.

Lors de l'installation, et non en tant que dépendance

Lors de l'installation, et non en tant que dépendance.
Lors de l'installation, et non en tant que dépendance.

Cette méthode est semblable à celle ci-dessus, mais elle ne retarde pas l'installation et ne l'échoue pas si la mise en cache échoue.

Idéal pour:les ressources plus importantes qui ne sont pas nécessaires immédiatement, telles que les éléments des niveaux ultérieurs d'un jeu.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

L'exemple ci-dessus ne transmet pas la promesse cache.addAll pour les niveaux 11 à 20 à event.waitUntil. Par conséquent, même en cas d'échec, le jeu reste disponible hors connexion. Bien entendu, vous devez vous adapter à l'éventuelle absence de ces niveaux et réessayer de les mettre en cache s'ils sont manquants.

Le service worker peut être arrêté pendant le téléchargement des niveaux 11 à 20, car il a terminé de gérer les événements. Ils ne seront donc pas mis en cache. À l'avenir, l'API Web Periodic Background Sync gérera ce type de cas et les téléchargements plus volumineux, comme les films. Cette API n'est actuellement compatible qu'avec les fourchettes Chromium.

À l'activation

À l'activation.
À l'activation.

Idéal pour:le nettoyage et la migration.

Une fois qu'un nouveau service worker a été installé et qu'une version précédente n'est plus utilisée, le nouveau s'active et vous recevez un événement activate. Étant donné que l'ancienne version n'est plus utilisée, il est temps de gérer les migrations de schémas dans IndexedDB et de supprimer les caches inutilisés.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

Lors de l'activation, d'autres événements tels que fetch sont placés dans une file d'attente. Une activation longue peut donc potentiellement bloquer le chargement des pages. Faites en sorte que votre activation soit aussi légère que possible et n'utilisez-la que pour les tâches que vous ne pouviez pas effectuer lorsque l'ancienne version était active.

Sur trained-to-thrill, je l'utilise pour supprimer les anciens caches.

En cas d'interaction de l'utilisateur

En cas d'interaction de l'utilisateur.
En cas d'interaction de l'utilisateur.

Idéal pour:lorsque vous ne pouvez pas mettre l'intégralité du site hors connexion et que vous avez choisi d'autoriser l'utilisateur à sélectionner les contenus qu'il souhaite mettre à disposition hors connexion. Par exemple : une vidéo sur YouTube, un article sur Wikipedia ou une galerie spécifique sur Flickr.

Proposez à l'utilisateur un bouton "Lire plus tard" ou "Enregistrer pour lire hors connexion". Lorsque l'utilisateur clique dessus, récupérez ce dont vous avez besoin sur le réseau et placez-le dans le cache.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

L'API caches est disponible à partir des pages et des service workers. Vous pouvez donc ajouter des éléments au cache directement depuis la page.

Lors de la réponse du réseau

Sur la réponse du réseau.
Lors de la réponse du réseau.

Idéal pour:mettre à jour fréquemment des ressources telles que la boîte de réception d'un utilisateur ou le contenu d'un article. Cette option est également utile pour les contenus non essentiels tels que les avatars, mais il faut faire preuve de prudence.

Si une requête ne correspond à aucun élément du cache, récupérez-la sur le réseau, envoyez-la à la page et ajoutez-la au cache en même temps.

Si vous le faites pour une plage d'URL, comme des avatars, vous devez faire attention à ne pas gonfler l'espace de stockage de votre origine. Si l'utilisateur doit récupérer de l'espace disque, vous ne voulez pas être le premier candidat. Assurez-vous de vous débarrasser des éléments du cache dont vous n'avez plus besoin.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

Pour optimiser l'utilisation de la mémoire, vous ne pouvez lire le corps d'une réponse ou d'une requête qu'une seule fois. Le code ci-dessus utilise .clone() pour créer des copies supplémentaires pouvant être lues séparément.

Sur trained-to-thrill, je l'utilise pour mettre en cache des images Flickr.

Stale-while-revalidate

Obsolète pendant la revalidation.
Stale-while-revalidate.

Idéal:mettre à jour fréquemment des ressources pour lesquelles il n'est pas essentiel de disposer de la toute dernière version. Les avatars peuvent appartenir à cette catégorie.

Si une version mise en cache est disponible, utilisez-la, mais récupérez une mise à jour pour la prochaine fois.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

Cette méthode est très semblable à la méthode HTTP stale-while-revalidate.

Lors de l'envoi du message

Message push.
Sur un message push.

L'API Push est une autre fonctionnalité basée sur le service worker. Cela permet au service worker d'être réveillé en réponse à un message du service de messagerie de l'OS. Cela se produit même si l'utilisateur n'a pas d'onglet ouvert sur votre site. Seul le service worker est réveillé. Vous demandez l'autorisation de le faire à partir d'une page, et l'utilisateur est invité à le faire.

Idéal pour:le contenu lié à une notification, comme un message de chat, une alerte info ou un e-mail. Contenus qui changent rarement et qui bénéficient d'une synchronisation immédiate, comme la mise à jour d'une liste de tâches ou la modification d'un agenda.

Le résultat final le plus courant est une notification qui, lorsqu'elle est enfoncée, ouvre/met au premier plan une page pertinente, mais pour laquelle la mise à jour des caches avant cela est extrêmement importante. L'utilisateur est visiblement en ligne au moment de la réception du message push, mais ce n'est peut-être pas le cas lorsqu'il interagit finalement avec la notification. Il est donc important de rendre ce contenu disponible hors connexion.

Ce code met à jour les caches avant d'afficher une notification:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

Sur background-sync

Synchronisation en arrière-plan activée.
Sur background-sync.

La synchronisation en arrière-plan est une autre fonctionnalité basée sur le service worker. Elle vous permet de demander la synchronisation des données en arrière-plan de façon ponctuelle ou selon un intervalle (extrêmement heuristique). Cela se produit même si l'utilisateur n'a pas d'onglet ouvert sur votre site. Seul le service worker est activé. Vous demandez l'autorisation de le faire à partir d'une page, et l'utilisateur est invité à le faire.

Idéal pour:les mises à jour non urgentes, en particulier celles qui se produisent si régulièrement qu'un message push par mise à jour serait trop fréquent pour les utilisateurs (comme des chronologies sur les réseaux sociaux ou des articles d'actualité).

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Persistance du cache

Votre origine dispose d'un certain espace libre à utiliser comme elle le souhaite. Cet espace libre est partagé entre tous les espaces de stockage d'origine : Stockage(local), IndexedDB, Accès au système de fichiers et, bien sûr, Caches.

Le montant que vous obtenez n'est pas spécifié. Elle varie en fonction de l'appareil et des conditions de stockage. Pour connaître le montant de votre solde:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

Toutefois, comme pour tout stockage de navigateur, le navigateur est libre de supprimer vos données si l'espace de stockage de l'appareil est insuffisant. Malheureusement, le navigateur ne peut pas faire la différence entre les films que vous souhaitez conserver à tout prix et le jeu qui ne vous intéresse pas vraiment.

Pour contourner ce problème, utilisez l'interface StorageManager:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

Bien entendu, l'utilisateur doit autoriser l'accès. Pour ce faire, utilisez l'API Permissions.

Il est important d'intégrer l'utilisateur à ce flux, car nous pouvons désormais nous attendre à ce qu'il contrôle la suppression. Si l'espace de stockage de l'appareil est insuffisant et que la suppression des données non essentielles ne résout pas le problème, l'utilisateur peut choisir les éléments à conserver et à supprimer.

Pour que cela fonctionne, les systèmes d'exploitation doivent traiter les origines "durables" comme des applications spécifiques à la plate-forme dans leur répartition de l'utilisation de l'espace de stockage, plutôt que de signaler le navigateur comme un seul élément.

Suggestions de diffusion : répondre aux requêtes

Peu importe la quantité de mise en cache que vous effectuez, le service worker n'utilise pas le cache, sauf si vous lui indiquez quand et comment. Voici quelques modèles de traitement des requêtes:

Cache uniquement

Cache uniquement.
Cache uniquement.

Idéal pour:tout élément que vous considérez comme statique pour une "version" particulière de votre site. Vous devez les avoir mises en cache dans l'événement d'installation afin de pouvoir compter sur leur présence.

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

…bien que vous n'ayez pas souvent besoin de gérer ce cas spécifiquement, Cache, falling back to network (Cache, recours au réseau) le couvre.

Réseau uniquement

Réseau uniquement.
Réseau uniquement.

Idéal pour:les éléments qui n'ont pas d'équivalent hors connexion, tels que les pings d'analyse, les requêtes autres que GET.

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

…bien que vous n'ayez pas souvent besoin de gérer ce cas spécifiquement, Cache, falling back to network (Cache, recours au réseau) le couvre.

Cache, retour au réseau

Cache, puis réseau.
Cache, puis réseau.

Idéal pour:créer d'abord hors connexion. Dans de tels cas, voici comment vous allez gérer la majorité des requêtes. D'autres formats constituent des exceptions en fonction de la requête entrante.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Vous obtenez ainsi le comportement "cache uniquement" pour les éléments du cache et le comportement "réseau uniquement" pour tout élément non mis en cache (ce qui inclut toutes les requêtes autres que GET, car elles ne peuvent pas être mises en cache).

Course entre le cache et le réseau

Course entre le cache et le réseau.
Course entre le cache et le réseau.

Idéal pour:les petits composants pour lesquels vous recherchez des performances sur des appareils dont l'accès au disque est lent.

Avec certaines combinaisons d'anciens disques durs, de programmes antivirus et de connexions Internet plus rapides, l'obtention de ressources à partir du réseau peut être plus rapide que d'accéder au disque. Toutefois, accéder au réseau lorsque l'utilisateur dispose du contenu sur son appareil peut entraîner un gaspillage de données. Gardez cela à l'esprit.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

Réseau qui utilise le cache

Le réseau retourne au cache.
Le réseau revient au cache.

Idéal pour:une solution rapide pour les ressources qui sont mises à jour fréquemment, en dehors de la "version" du site. Par exemple : articles, avatars, chronologies sur les réseaux sociaux et classements de jeux.

Cela signifie que vous proposez aux utilisateurs en ligne le contenu le plus récent, mais que les utilisateurs hors connexion obtiennent une version plus ancienne mise en cache. Si la requête réseau aboutit, vous devrez probablement mettre à jour l'entrée de cache.

Cependant, cette méthode présente des failles. Si l'utilisateur dispose d'une connexion intermittente ou lente, il devra attendre que le réseau échoue avant de pouvoir accéder au contenu parfaitement acceptable déjà présent sur son appareil. Cela peut prendre un temps extrêmement long et l'expérience utilisateur est frustrante. Pour obtenir une meilleure solution, consultez le modèle suivant, Cacher, puis mettre en réseau.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

Cache, puis réseau

Cache, puis réseau.
Cache, puis réseau.

Idéal pour:contenus mis à jour fréquemment. (articles, chronologies des réseaux sociaux, jeux, classements, etc.).

Pour ce faire, la page doit envoyer deux requêtes, l'une au cache et l'autre au réseau. L'idée est d'afficher d'abord les données mises en cache, puis de mettre à jour la page lorsque/si les données réseau arrivent.

Parfois, vous pouvez simplement remplacer les données actuelles lorsque de nouvelles données arrivent (par exemple, le classement du jeu), mais cela peut être perturbant pour les contenus plus volumineux. En gros, ne faites pas "disparaître " un élément que l'utilisateur peut lire ou avec lequel il peut interagir.

Twitter ajoute le nouveau contenu au-dessus de l'ancien et ajuste la position de défilement pour que l'utilisateur ne soit pas interrompu. Cela est possible, car Twitter conserve principalement son contenu dans un ordre principalement linéaire. J'ai copié ce modèle pour Trained-to-Thrill afin d'afficher le contenu à l'écran aussi rapidement que possible, tout en affichant le contenu à jour dès qu'il arrive.

Code dans la page:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Code dans le service worker:

Vous devez toujours accéder au réseau et mettre à jour un cache au fur et à mesure.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

Dans trained-to-thrill, j'ai contourné ce problème en utilisant XHR au lieu de fetch et en abusant de l'en-tête Accept pour indiquer au service worker d'où obtenir le résultat (code de page, code du service worker).

Solution de remplacement générique

Valeur par défaut générique.
Remplacement générique.

Si vous ne parvenez pas à diffuser quelque chose à partir du cache et/ou du réseau, vous pouvez fournir un remplacement générique.

Idéal pour:les images secondaires telles que les avatars, les requêtes POST ayant échoué et la page "Indisponible hors connexion".

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

L'élément de remplacement est probablement une dépendance d'installation.

Si votre page publie un e-mail, votre service worker peut stocker l'e-mail dans une "boîte de sortie" IndexedDB et répondre en informant la page que l'envoi a échoué, mais que les données ont été conservées.

Modèles côté service worker

Modèles côté service worker
Modélisation côté ServiceWorkers.

Idéal pour:les pages dont la réponse du serveur ne peut pas être mise en cache.

L'affichage des pages sur le serveur accélère les choses, mais cela peut impliquer d'inclure des données d'état qui n'ont peut-être pas de sens dans un cache (par exemple, "Connecté en tant que…"). Si votre page est contrôlée par un service worker, vous pouvez choisir de demander des données JSON avec un modèle et de l'afficher à la place.

importScripts('templating-engine.js');

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

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

Pour aller plus loin

Vous n'êtes pas limité à l'une de ces méthodes. En fait, vous en utiliserez probablement beaucoup en fonction de l'URL de la requête. Par exemple, trained-to-thrill utilise:

Il vous suffit d'examiner la demande et de décider de la marche à suivre:

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

…vous avez compris.

Crédits

…pour les belles icônes:

Merci à Jeff Posnick d'avoir détecté de nombreuses erreurs avant que je n'appuie sur "Publier".

Documentation complémentaire