Livre de recettes hors connexion

Jake Archibald
Jake Archibald

Avec Service Worker, nous avons renoncé à essayer de résoudre des problèmes hors connexion, et nous avons donné aux développeurs les moyens nécessaires pour résoudre le problème eux-mêmes. Elle vous permet de contrôler la mise en cache et la façon dont les requêtes sont traitées. Cela signifie que vous pouvez créer vos propres modèles. Intéressons-nous de manière isolée. Toutefois, en pratique, vous utiliserez probablement un grand nombre d'entre eux en tandem, en fonction de l'URL et du contexte.

Pour découvrir une démonstration fonctionnelle de certains de ces modèles, consultez la section Entraînement à la sensation et cette vidéo qui présente l'impact sur les performances.

La machine de mise en cache : quand stocker les ressources

Service Worker vous permet de gérer les requêtes indépendamment de la mise en cache, donc je vais vous les présenter séparément. Tout d'abord, pour la mise en cache, quand faut-il l'effectuer ?

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

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

Service Worker vous génère un événement install. Vous pouvez l'utiliser pour préparer les éléments qui doivent être prêts avant de gérer d'autres événements. Bien que cela se produise, n'importe quelle version précédente de votre service worker est toujours en cours d'exécution et de diffusion de pages. Par conséquent, les opérations que vous effectuez ici ne doivent pas l'interrompre.

Application idéale:CSS, images, polices, JS, modèles... Tout ce que vous considérez comme statique par rapport à cette "version" de votre site.

Il s'agit là d'éléments qui rendraient votre site totalement inopérant en cas d'échec de la récupération. Par exemple, une application équivalente spécifique à une plate-forme ferait partie du 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 accepte une promesse pour définir la durée et le succès de l'installation. Si la promesse est refusée, l'installation est considérée comme un échec et ce service worker est abandonné (si une ancienne version est en cours d'exécution, elle reste intacte). Engagements de retour caches.open() et cache.addAll(). Si l'une des ressources ne peut pas être récupérée, l'appel cache.addAll() est rejeté.

Sur l'entraînement à la puissance, je l'utilise pour mettre en cache les éléments statiques.

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

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

Ce processus est semblable à celui ci-dessus, mais il ne retarde pas la fin de l'installation et n'entraîne pas l'échec de l'installation en cas d'échec de la mise en cache.

Idéal pour:les ressources plus importantes qui ne sont pas nécessaires immédiatement, comme celles destinées aux niveaux suivants 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 des niveaux 11 à 20 à event.waitUntil. Par conséquent, même en cas d'échec, le jeu sera toujours disponible hors connexion. Bien sûr, vous devez vous adapter à l'absence possible 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 fini de traiter les événements. Ils ne seront donc pas mis en cache. À l'avenir, l'API WebPeriodic Background Synchronization gérera les cas de ce type, ainsi que les téléchargements plus volumineux tels que les films. Cette API n'est actuellement compatible qu'avec les duplications Chromium.

Lors de l'activation

Activer.
Lors de l'activation.

Idéal pour:nettoyage et migration.

Une fois qu'un nouveau service worker est installé et qu'une version précédente n'est pas utilisée, le nouveau service s'active, et vous recevez un événement activate. Comme l'ancienne version est obsolète, il est recommandé de gérer les migrations de schémas dans IndexedDB et de supprimer également 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 mis en file d'attente. Par conséquent, une activation longue peut potentiellement bloquer le chargement des pages. Réduisez le plus possible votre processus d'activation et ne l'utilisez que pour des opérations que vous ne pouviez pas effectuer lorsque l'ancienne version était active.

Sur l'entraînement à la puissance, 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:lorsque l'ensemble du site ne peut pas être hors connexion et que vous avez choisi d'autoriser l'utilisateur à sélectionner le contenu qu'il souhaite rendre disponible hors connexion. par exemple une vidéo sur YouTube, un article sur Wikipédia ou une galerie spécifique sur Flickr.

Attribuez à l'utilisateur un bouton "Lire plus tard" ou "Enregistrer pour accès hors connexion". Lorsque vous cliquez 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, ce qui signifie que vous pouvez ajouter au cache directement depuis la page.

Sur la réponse du réseau

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

Idéal pour:mises à jour fréquentes de ressources telles que la boîte de réception d'un utilisateur ou le contenu d'articles. Cette fonctionnalité est également utile pour les contenus non essentiels tels que les avatars, mais elle doit faire l'objet d'une attention particulière.

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 en même temps au cache.

Si vous effectuez cette opération pour plusieurs URL, telles que les avatars, vous devez veiller à ne pas surcharger l'espace de stockage de votre origine. Si l'utilisateur a besoin de récupérer de l'espace disque, vous ne voulez pas être le premier candidat. Assurez-vous de supprimer les é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 permettre une utilisation efficace de la mémoire, vous ne pouvez lire le corps d'une réponse/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.

Lors de l'entraînement à la puissance, je l'utilise pour mettre en cache les images Flickr.

Obsolète pendant la revalidation

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

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

Si une version 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 stale-while-revalidate de HTTP.

Lors d'un message push

Lors du message push.
Lors d'un message push.

L'API Push est une autre fonctionnalité basée sur Service Worker. Cela permet au service worker d'être réveillé en réponse à un message du service de messagerie du système d'exploitation. Cela se produit même lorsque l'utilisateur n'a pas ouvert d'onglet sur votre site. Seul le service worker est activé. Vous demandez l'autorisation d'effectuer cette opération à partir d'une page, et l'utilisateur sera invité.

Idéal pour:contenus en rapport avec une notification, comme un message de chat, un reportage ou un e-mail. Vous pouvez aussi modifier rarement du contenu qui bénéficie d'une synchronisation immédiate (mise à jour de la liste de tâches ou modification de l'agenda, par exemple).

Le résultat final courant est une notification qui, lorsque l'utilisateur appuie dessus, ouvre ou sélectionne une page pertinente. Toutefois, la mise à jour des caches avant que cela ne se produise est extremely importante. L'utilisateur est évidemment en ligne au moment de la réception du message push, mais il est possible qu'il ne le soit pas lorsqu'il interagit enfin 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 la synchronisation en arrière-plan

sur la synchronisation en arrière-plan.
Sur la synchronisation en arrière-plan.

La synchronisation en arrière-plan est une autre fonctionnalité basée sur Service Worker. Elle vous permet de demander la synchronisation des données en arrière-plan de manière ponctuelle ou à intervalle (extrêmement heuristique). Cela se produit même lorsque l'utilisateur n'a pas ouvert d'onglet pour accéder à votre site. Seul le service worker est activé. Lorsque vous demandez l'autorisation d'effectuer cette opération à partir d'une page, l'utilisateur y est invité.

Idéal pour les situations non urgentes

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 pour faire ce qu'elle souhaite. Cet espace disponible est partagé entre l'ensemble du stockage d'origine : le stockage(local), IndexedDB, l'accès au système de fichiers et, bien sûr, les caches.

Le montant reçu n'est pas spécifié. Elle varie en fonction de l'appareil et des conditions de stockage. Pour le savoir, vous pouvez:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

Toutefois, comme pour tout espace de stockage sur un navigateur, celui-ci ne risque pas de jeter vos données si l'appareil est soumis à une pression de stockage. Malheureusement, le navigateur ne fait pas 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 accorder l'autorisation. Pour ce faire, utilisez l'API Permissions.

Il est important d'inclure l'utilisateur dans ce flux, car nous pouvons désormais nous attendre à ce qu'il contrôle la suppression. Si son appareil est soumis à une pression de stockage et que la suppression des données non essentielles ne résout pas le problème, l'utilisateur peut juger des éléments à conserver et à supprimer.

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

Diffusion des suggestions : réponse aux demandes

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 dans une "version" particulière de votre site. Vous devez les avoir mises en cache dans l'événement d'installation. Vous pouvez donc 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, le cache, le retour au réseau le couvre.

Réseau uniquement

Réseau uniquement.
Réseau uniquement.

Idéal pour:éléments qui n'ont pas d'équivalent hors connexion, tels que les pings d'analyse ou 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, le cache, le retour au réseau le couvre.

Cache, retour au réseau

Cache, en revenant au réseau.
Mise en cache, retour au réseau.

Solution idéale:créer des applications orientées hors connexion. Dans ce cas, c'est ainsi que vous allez gérer la majorité des requêtes. D'autres formats sont 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);
    }),
  );
});

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

Cache et concurrence de réseau

Cache et concurrence au réseau
Mise en concurrence du cache et du réseau

Idéal pour:les éléments de petite taille dont vous visez les performances sur les appareils dont l'accès au disque est lent.

Avec certaines combinaisons d'anciens disques durs, d'analyseurs de virus et de connexions Internet plus rapides, il peut être plus rapide d'extraire des ressources du réseau que d'aller sur disque. Toutefois, se rendre sur le réseau lorsque l'utilisateur dispose du contenu sur son appareil peut représenter 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)]));
});

Retour au cache du réseau

Retour au cache du réseau
Retour au cache du réseau

Idéal pour:correction rapide des ressources qui se mettent à jour fréquemment, en dehors de la "version" du site. Par exemple, des articles, des avatars, des chronologies des réseaux sociaux et des classements de jeu.

Cela signifie que vous fournissez aux utilisateurs en ligne le contenu le plus récent, mais que ceux qui ne le sont pas obtiennent une version plus ancienne mise en cache. Si la requête réseau aboutit, vous souhaiterez probablement mettre à jour l'entrée de cache.

Cependant, cette méthode présente des défauts. Si l'utilisateur a une connexion intermittente ou lente, il doit attendre que le réseau échoue avant d'obtenir le contenu parfaitement acceptable déjà sur son appareil. Cette opération peut prendre beaucoup de temps et constitue une expérience utilisateur frustrante. Consultez le modèle suivant, Cache puis réseau, pour trouver une meilleure solution.

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

Mise en cache, puis réseau

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

Idéal pour:contenus fréquemment mis à jour. Exemples : articles, chronologie des réseaux sociaux, jeux, classements généraux.

Dans ce cas, 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 quand/si les données réseau arrivent.

Parfois, vous pouvez simplement remplacer les données actuelles lorsque de nouvelles données arrivent (par exemple, un classement de jeu), mais cela peut perturber l'importation de contenus plus volumineux. Fondamentalement, ne « disparaissez pas » quelque chose que l'utilisateur pourrait lire ou avec lequel 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 pour la plupart un ordre de contenu essentiellement linéaire. J'ai copié ce modèle pour entraîné à la pratique afin d'afficher le contenu à l'écran le plus rapidement 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 devriez 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;
      });
    }),
  );
});

Pour contourner ce problème, j'ai utilisé XHR au lieu de la récupération et utilisé l'en-tête Accept pour indiquer au service worker où obtenir le résultat (code de page, code Service Worker).

Création de remplacement générique

Valeur de remplacement générique.
Générique de remplacement.

Si vous ne parvenez pas à diffuser un élément du cache et/ou du réseau, vous pouvez fournir une solution générique de remplacement.

Idéal pour:images secondaires telles que les avatars, les requêtes POST ayant échoué et une 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 à utiliser de remplacement est probablement une dépendance d'installation.

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

Modèles côté service worker

Modèles côté ServiceWorker
Modèles côté ServiceWorker
.

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

Le rendu des pages sur le serveur est plus rapide, mais cela peut impliquer l'inclusion de données d'état qui peuvent ne pas avoir de sens dans un cache, telles que "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 les 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 un grand nombre, selon l'URL de la requête. Par exemple, l'expression trained-to-thrill utilise:

Examinez simplement la demande et décidez de la procédure à 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 prenez une photo.

Crédits

... pour ces belles icônes:

Merci à Jeff Posnick d'avoir détecté de nombreuses erreurs hurlantes avant que je ne clique sur "Publier".

Complément d'informations