Construire une application web sous forme de Progressive Web App (PWA) introduit la possibilité d'un d'accès hors ligne, offrant des expériences fluides même dans des conditions réseau instable. Un aspect crucial de la construction d'une PWA robuste est la mise en cache, qui garantit que tous les éléments nécessaires sont disponibles lorsque les utilisateurs sont hors ligne. Cet article explore la mise en cache de resources dynamiques à l'aide des Service Workers, en mettant l'accent sur l'implémentation via la bibliothèque Workbox et le plugin Vite vite-plugin-pwa
Le défi : Ressources statiques vs dynamiques
Dans notre PWA, les ressources peuvent être classées comme suit:
Ressources statiques: Cela inclut des fichiers tels que HTML, CSS, JS et les icônes, générés au moment du build
Ressources dynamiques: Ces fichiers sont mis à jour depuis notre back-office sans nécessiter un nouveau build ou un redéploiement. Par exemple, les nouveaux contenus comme des fichiers audio, des images et des données récupérées via des API
Bien que la mise en cache des ressources statiques soit simple avec des outils comme Workbox, la mise en cache des ressources dynamiques pour une utilisation hors ligne pose un certain nombre de défis. La question est: comment mettre en cache efficacement des ressources dynamiques tout en garantissant qu'elles restent à jour?
L'implémentation
Pour mettre en cache les ressources statiques, nous utiliserons la fonctionnalité injectManifest de Workbox. Voici la configuration vite.config.ts
utilisant vite-plugin-pwa:
{//...injectManifest: {globPatterns: ["**/*.{js,css,html,svg,png,ico}"],swSrc: "assets-service-worker.ts"},// ...}
Ensuite, dans notre implémentation personnalisée de Service Worker (assets-service-worker.ts)
, nous enregistrons le manifeste des ressources générées lors de la phase de build en utilisant workbox-precaching
precacheAndRoute(self.__WB_MANIFEST);
Pour mettre en cache les ressources dynamiques (ressources pouvant évoluer au cours de la durée de vie d'un build de l'application), notre API doit exposer une liste de ressources dynamiques au format suivant:
type AssetType = {name: string;url: string;revision: string | null;}
Dans notre cas, les ressources dynamiques incluent :
Image
Sons
Endpoint REST
Principaux défis
Un des principaux défis est de maintenir la liste des ressources dynamiques à jour. Au lieu de mettre à jour le cache uniquement lors de l'installation du Service Worker, nous actualisons la liste à chaque chargement de l'application en:
Écoutant les événements
fetch
depuis le Service WorkerDéclenchant la mise en cache uniquement pour les requêtes de navigation (requêtes
doc
).
Fetch des ressources dynamiques
Le Service Worker écoute les requêtes de navigation et déclenche la mise en cache dynamique des ressources:
self.addEventListener("fetch", (event) => {const isNavigate = event.request.mode === "navigate";if (navigator.onLine && isNavigate) {fetchAndCacheDynamicAssets();}});
Récupération et peuplement du cache
Les listes de ressources dynamiques sont récupérées depuis l'API et mises dans un cache dédié:
async function fetchAndCacheDynamicAssets() {try {const response = await restClient.assets.search({query: { limit: 100_000 },});if (response.status !== 200) {throw new Error("Failed to fetch assets for preloading");}const assets = response.body;// Manually cache assetsconst dynAssetsCache = await caches.open(DYNAMIC_CACHE_NAME);const storeAssetsInCachePromises = assets.map(async ({ url, revision }) => {const assetUrlWithRevision = revision ? `${url}?rev=${revision}` : url;const cacheMatch = await dynAssetsCache.match(assetUrlWithRevision);if (!cacheMatch) {const assetResponse = await fetch(url);if (assetResponse.ok) {await dynAssetsCache.put(assetUrlWithRevision, assetResponse);}}});await Promise.all(storeAssetsInCachePromises);} catch (error) {console.error("Failed to fetch assets for preloading", error);}}
Enfin, nous enregistrons une route Workbox pour servir ces ressources depuis le cache dédié:
registerRoute(({ request, url }) => {return (url.pathname.startsWith("/api") ||request.destination === "image" ||request.destination === "audio");},new StaleWhileRevalidate({cacheName: DYNAMIC_CACHE_NAME,matchOptions: { ignoreSearch: true }, // to ignore the revision ("rev") query param}));
La dernière partie de notre implémentation de Service Worker consiste à servir toutes les requêtes de navigation avec le fichier index.html
afin de garantir le fonctionnement de type SPA en mode offline:
registerRoute(new NavigationRoute(createHandlerBoundToURL("/index.html")));
Code final du Service Worker:
import { createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";import { NavigationRoute, registerRoute } from "workbox-routing";import { StaleWhileRevalidate } from "workbox-strategies";import { restClient } from "./infrastructure/query.client";declare const self: ServiceWorkerGlobalScope;const DYNAMIC_CACHE_NAME = "dynamic-assets-cache";// Fetch asset list during doc (index.html) requestself.addEventListener("fetch", (event) => {const isNavigate = event.request.mode === "navigate";if (navigator.onLine && isNavigate) {fetchAndCacheDynamicAssets();}});precacheAndRoute(self.__WB_MANIFEST);// All Navigation requests will be served with the index.html file (no SSR)registerRoute(new NavigationRoute(createHandlerBoundToURL("/index.html")));registerRoute(({ request, url }) => {return (url.pathname.startsWith("/api") ||request.destination === "image" ||request.destination === "audio");},new StaleWhileRevalidate({cacheName: DYNAMIC_CACHE_NAME,matchOptions: { ignoreSearch: true }, // to ignore the "rev" query param}));async function fetchAndCacheDynamicAssets() {try {const response = await restClient.assets.search({query: { limit: 100_000 },});if (response.status !== 200) {throw new Error("Failed to fetch assets for preloading");}const assets = response.body;// Manually cache assetsconst dynAssetsCache = await caches.open(DYNAMIC_CACHE_NAME);const storeAssetsInCachePromises = assets.map(async ({ url, revision }) => {const assetUrlWithRevision = revision ? `${url}?rev=${revision}` : url;const cacheMatch = await dynAssetsCache.match(assetUrlWithRevision);if (!cacheMatch) {const assetResponse = await fetch(url);if (assetResponse.ok) {await dynAssetsCache.put(assetUrlWithRevision, assetResponse);}}});await Promise.all(storeAssetsInCachePromises);} catch (error) {console.error("Failed to fetch assets for preloading", error);}}
Conclusion
En combinant Workbox avec un Service Worker personnalisé, nous pouvons gérer efficacement les ressources dynamiques dans notre PWA. Cette approche garantit que les ressources restent à jour et accessibles hors ligne, améliorant ainsi l'expérience utilisateur. Le préchargement des ressources statiques avec injectManifest
et la mise en cache dynamique des ressources pendant l'utilisation de l'application trouve le juste équilibre entre efficacité et flexibilité.