Livre de recettes hors connexion

Jake Archibald
Jake Archibald

Avec le service worker, nous avons abandonné la tentative de résoudre le problème hors connexion et avons donné aux développeurs les pièces mobiles pour le résoudre eux-mêmes. Il vous permet de contrôler le 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 en isolation, mais en pratique, vous en utiliserez probablement beaucoup en tandem en fonction de l'URL et du contexte.

Pour voir 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 de cache : quand stocker des 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, quand doit-on mettre en cache ?

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.

Le service worker vous fournit 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:CSS, images, polices, JS, modèles… en gros, tout ce que vous considéreriez comme statique pour 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 devrez tenir compte de l'absence éventuelle 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.

À activer

À 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, ce qui signifie que vous pouvez ajouter des éléments 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:mettre à jour fréquemment des ressources telles que la boîte de réception d'un utilisateur ou le contenu d'un article. Utile également pour les contenus non essentiels tels que les avatars, mais attention.

Si une requête ne correspond à rien dans le 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 candidat principal. 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 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.

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

Stale-while-revalidate

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

Idéal pour:les ressources fréquemment mises à jour pour lesquelles la dernière version n'est pas indispensable. 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 valeur est très semblable à stale-while-revalidate dans HTTP.

Message d'activation

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 évidemment en ligne au moment de la réception du message push, mais il ne l'est peut-être pas au moment de l'interaction finale 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

Sur background-sync.
Sur background-sync.

La synchronisation en arrière-plan est une autre fonctionnalité basée sur le service worker. Il vous permet de demander la synchronisation des données en arrière-plan de manière ponctuelle ou à 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 réveillé. 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 sont si fréquentes qu'un message push par mise à jour serait trop fréquent pour les utilisateurs (par exemple, les flux de réseaux sociaux ou les articles d'actualités).

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é. Il 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 que l'utilisateur fasse partie de 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'utilisera pas le cache que vous lui indiquez quand et comment. Voici quelques exemples de modèles de gestion 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, puis retour au réseau

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

Idéal pour:créer une application orientée hors connexion. Dans ce cas, c'est ainsi que vous gérerez la majorité des requêtes. Les autres modèles seront 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 de disques durs plus anciens, 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 revient 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.

Toutefois, 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:les contenus mis à jour fréquemment. Par exemple : articles, chronologies sur les réseaux sociaux et classements de jeux.

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 une organisation linéaire pour la plupart des contenus. 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 auquel vous faites appel 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é service worker.

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