Avec les service workers, nous avons donné aux développeurs un moyen de résoudre les problèmes de connexion réseau. Vous contrôlez la mise en cache et le traitement des requêtes. Cela signifie que vous pouvez créer vos propres motifs. Examinons quelques modèles possibles de manière isolée, mais en pratique, vous les utiliserez probablement ensemble, en fonction de l'URL et du contexte.
Pour obtenir une démo fonctionnelle de certains de ces modèles, consultez Trained-to-thrill.
Quand stocker des ressources
Les service workers vous permettent de gérer les requêtes indépendamment de la mise en cache. Je vais donc vous les présenter séparément. Commençons par déterminer quand vous devez utiliser le cache.
Lors de l'installation, en tant que dépendance
L'API Service Worker vous fournit un événement install. Vous pouvez l'utiliser pour préparer des éléments qui doivent l'être avant que vous ne gériez d'autres événements. Pendant install, les versions précédentes de votre service worker continuent de s'exécuter et de diffuser des pages. Tout ce que vous faites à ce moment-là ne devrait pas perturber le service worker existant.
Idéal pour : CSS, images, polices, JS, modèles ou tout autre élément que vous considérez comme statique pour cette version de votre site.
Récupérez les éléments qui rendraient votre site totalement dysfonctionnel s'ils ne pouvaient pas être récupérés, c'est-à-dire les é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 le succès de l'installation. Si la promesse est rejeté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 est laissée 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 rejeté.
Sur trained-to-thrill, je l'utilise pour mettre en cache les éléments statiques.
À l'installation, et non en tant que dépendance
Cela revient à installer le package en tant que dépendance, mais sans retarder la fin de l'installation ni provoquer son échec en cas d'échec de la mise en cache.
Idéal pour : les ressources plus volumineuses qui ne sont pas nécessaires immédiatement, comme 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
();
}),
);
});
Cet exemple 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 restera disponible hors connexion. Bien sûr, vous devrez tenir compte de l'absence possible de ces niveaux et réessayer de les mettre en cache s'ils manquent.
Le service worker peut être arrêté pendant le téléchargement des niveaux 11 à 20, car il a fini de gérer les événements. Cela signifie qu'ils ne seront pas mis en cache. L'API Web Periodic Background Synchronization peut gérer ce type de cas, ainsi que les téléchargements plus volumineux tels que les films.
Activer
Idéal pour : le nettoyage et la migration.
Une fois qu'un nouveau service worker est installé et qu'une version précédente n'est plus utilisée, le nouveau s'active et vous recevez un événement activate. Maintenant que l'ancienne version est supprimée, c'est le bon moment pour gérer les migrations de schéma dans IndexedDB et 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, des événements tels que fetch sont mis en file d'attente. Une activation longue peut donc bloquer le chargement des pages. Faites en sorte que votre activation soit aussi légère que possible et ne l'utilisez que pour les choses que vous ne pouviez pas faire lorsque la version précédente était active.
Sur trained-to-thrill, je l'utilise pour supprimer les anciens caches.
En cas d'interaction de l'utilisateur
Idéal : lorsque l'ensemble du site ne peut pas être mis 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.
Fournissez à l'utilisateur un bouton "Lire plus tard" ou "Enregistrer pour une utilisation hors connexion". Lorsqu'il est cliqué, 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 Cache est disponible à partir des pages et des service workers, ce qui signifie que vous pouvez ajouter des éléments au cache directement à partir de la page.
Réponse du réseau
Idéal pour : les ressources fréquemment mises à jour, comme la boîte de réception d'un utilisateur ou le contenu d'un article. Également utile pour le contenu non essentiel tel que les avatars, mais avec précaution.
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 devrez veiller à ne pas gonfler le 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 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. L'exemple de code utilise .clone() pour créer des copies supplémentaires qui peuvent être lues séparément.
Sur trained-to-thrill, je l'utilise pour mettre en cache les images Flickr.
Stale-while-revalidate
Idéal pour : les ressources fréquemment mises à jour 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 fonctionnalité est très semblable à stale-while-revalidate de HTTP.
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 ouvert d'onglet vers votre site. Seul le service worker est réactivé. Vous demandez l'autorisation de le faire à partir d'une page et l'utilisateur est invité à répondre.
Idéal pour : le contenu lié à une notification, comme un message de chat, une information de dernière minute ou un e-mail. Contenu qui change peu souvent et qui bénéficie 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 courant est une notification qui, lorsqu'elle est sélectionnée, ouvre et met en évidence une page pertinente, et pour laquelle la mise à jour des caches au préalable est extrêmement importante. L'utilisateur est en ligne au moment où il reçoit le message push, mais il peut ne plus l'être lorsqu'il interagit enfin avec la notification. Il est donc essentiel 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/');
}
});
À propos de la synchronisation en arrière-plan
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éactivé. Vous demandez l'autorisation de le faire à partir d'une page, et l'utilisateur est invité à répondre.
Idéal pour : les informations non urgentes, en particulier celles qui sont mises à jour si régulièrement qu'un message push par mise à jour serait trop fréquent pour les utilisateurs, comme les timelines de réseaux sociaux ou les 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'une certaine quantité d'espace libre pour faire ce qu'elle veut. Cet espace libre est partagé entre tous les espaces de stockage d'origine : Stockage(local), IndexedDB, File System Access et, bien sûr, Caches.
Le montant que vous recevez n'est pas spécifié. Cela dépend de l'appareil et des conditions de stockage. Pour connaître le montant de votre solde, utilisez la commande suivante :
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'appareil est soumis à une pression de stockage. 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 sûr, l'utilisateur doit accorder l'autorisation. 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 son appareil est saturé 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 leurs répartitions de l'utilisation du stockage, plutôt que de signaler le navigateur comme un seul élément.
Suggestions de diffusion
Peu importe la quantité de mise en cache que vous effectuez, le service worker n'utilise le cache que lorsque vous lui indiquez quand et comment. Voici quelques schémas pour gérer les demandes :
Cache uniquement
Idéal pour : tout ce que vous considérez comme statique pour une version spécifique de votre site. Vous devriez les avoir mis 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, Cache, avec retour au réseau le couvre.
Réseau uniquement
Idéal pour : les éléments qui n'ont pas d'équivalent hors connexion, comme les pings Analytics et les requêtes autres que GET.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or 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, avec retour au réseau le couvre.
Cache, avec retour au réseau
Idéal pour : créer des applications orientées hors connexion. Dans ce cas, voici comment traiter la majorité des demandes. Les autres schémas sont des exceptions basées sur la demande entrante.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
Cela vous donne le comportement "cache uniquement" pour les éléments du cache et le comportement "réseau uniquement" pour tout ce qui n'est pas mis en cache (y compris toutes les requêtes non GET, car elles ne peuvent pas être mises en cache).
Course entre le cache et le réseau
Idéal pour : les petits composants lorsque vous recherchez des performances sur des appareils dont l'accès au disque est lent.
Avec certaines combinaisons de disques durs plus anciens, d'antivirus et de connexions Internet plus rapides, il peut être plus rapide d'obtenir des ressources à partir du réseau 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 rejects when a promise rejects before fulfilling.
// To make a 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)]));
});
Le réseau revient au cache
Idéal pour : corriger rapidement les ressources qui sont mises à jour fréquemment, en dehors de la "version" du site. Par exemple, des articles, des avatars, des chronologies de réseaux sociaux et des classements de jeux.
Cela signifie que vous fournissez aux utilisateurs en ligne le contenu le plus récent, mais que les utilisateurs hors connexion obtiennent une version mise en cache plus ancienne. Si la requête réseau aboutit, vous souhaiterez 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 d'obtenir le contenu parfaitement acceptable déjà présent sur son appareil. Cela peut prendre beaucoup de temps et l'expérience utilisateur est frustrante. Pour une meilleure solution, consultez le modèle suivant, Cache puis réseau.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
Cache puis réseau
Idéal pour : les contenus mis à jour fréquemment. Par exemple, les articles, les chronologies des réseaux sociaux et les classements des jeux.
Cela nécessite que la page effectue deux requêtes : une vers le cache et une vers le réseau. L'idée est d'afficher d'abord les données mises en cache, puis de mettre à jour la page lorsque les données réseau arrivent (le cas échéant).
Parfois, vous pouvez simplement remplacer les données actuelles lorsque de nouvelles données arrivent (comme un classement de jeu), mais cela peut être perturbant avec des contenus plus volumineux. En d'autres termes, ne faites pas "disparaître" un élément que l'utilisateur est en train de lire ou avec lequel il interagit.
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 un ordre de contenu principalement linéaire. J'ai copié ce modèle pour trained-to-thrill 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 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 où obtenir le résultat (code de la page, code du service worker).
Remplacement générique
Si vous ne parvenez pas à diffuser un élément à partir du cache ou du réseau, fournissez un remplacement générique.
Idéal pour : les 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 auquel vous revenez est probablement une dépendance d'installation.
Si votre page publie un e-mail, votre service worker peut se rabattre sur le stockage de l'e-mail dans une boîte d'envoi IndexedDB et répondre en indiquant à la page que l'envoi a échoué, mais que les données ont été conservées.
Modèles côté service worker
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 n'ont pas de sens dans un cache, comme l'état de connexion. 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',
},
});
}),
);
});
Regrouper tous les éléments
Vous n'êtes pas limité à l'une de ces méthodes. En fait, vous en utiliserez probablement plusieurs en fonction de l'URL de la requête. Par exemple, trained-to-thrill utilise :
- Mise en cache lors de l'installation, pour l'UI et le comportement statiques
- Mise en cache de la réponse réseau pour les images et les données Flickr
- Récupérer à partir du cache, en revenant au réseau, pour la plupart des requêtes
- Récupérer depuis le cache, puis le réseau, pour les résultats de recherche Flickr
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);
}),
);
});
Documentation complémentaire
- Service workers et API Cache Storage
- JavaScript Promises—an Introduction : guide sur les promesses
Crédits
Pour les belles icônes :
- Code par buzzyrobot
- Calendar de Scott Lewis
- Network de Ben Rizzo
- SD de Thomas Le Bas
- CPU par iconsmind.com
- Trash par trasnik
- Notification par @daosme
- Layout par Mister Pixel
- Cloud de P.J. Onori
Merci à Jeff Posnick d'avoir détecté de nombreuses erreurs avant que je ne clique sur "Publier".