Recettes d'applications hors ligne





Bonne journée, mes amis!



Je présente à votre attention une traduction de l'excellent article de Jake Archibald "Offline Cookbook" consacré à divers cas d'utilisation de l'API ServiceWorker et de l'API Cache.



On suppose que vous connaissez les bases de ces technologies, car il y aura beaucoup de code et peu de mots.



Si vous n'êtes pas familier, commencez par MDN , puis revenez. Voici un autre bon article sur les techniciens de service spécifiquement pour les visuels.



Sans autre préambule.



Quand économiser les ressources?



Le worker vous permet de traiter les demandes indépendamment du cache, nous les considérerons donc séparément.



La première question est de savoir quand devez-vous mettre en cache les ressources?



Lorsqu'il est installé en tant que dépendance






L'un des événements qui se produit lorsqu'un worker est en cours d'exécution est l'événement d'installation. Cet événement peut être utilisé pour préparer la gestion d'autres événements. Lorsqu'un nouveau worker est installé, l'ancien continue de servir la page, donc la gestion de l'événement d'installation ne doit pas la casser.



Convient pour la mise en cache de styles, d'images, de scripts, de modèles ... en général, pour tous les fichiers statiques utilisés sur la page.



Nous parlons de ces fichiers sans lesquels l'application ne peut pas fonctionner comme les fichiers inclus dans le téléchargement initial des applications natives.



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


event.waitUntil accepte une promesse de déterminer la durée et le résultat de l'installation. Si la promesse est rejetée, le worker ne sera pas installé. caches.open et cache.addAll renvoient des promesses. Si l'une des ressources n'est pas disponible, l'

appel à cache.addAll sera rejeté.



Lorsqu'il n'est pas installé en tant que dépendance






Ceci est similaire à l'exemple précédent, mais dans ce cas, nous n'attendons pas la fin de l'installation, donc cela n'annulera pas l'installation.



Convient aux ressources importantes qui ne sont pas nécessaires pour le moment, telles que les ressources pour les niveaux ultérieurs du jeu.



self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('mygame-core-v1')
            .then(cache => {
                cache.addAll(
                    //  11-20
                )
                return cache.addAll(
                    //     1-10
                )
            })
    )
})


Nous ne transmettons pas la promesse cache.addAll à event.waitUntil pour les niveaux 11-20, donc si elle est rejetée, le jeu fonctionnera toujours hors ligne. Bien sûr, vous devez vous occuper des éventuels problèmes de mise en cache des premiers niveaux et, par exemple, réessayer de la mise en cache en cas d'échec.



Le worker peut être arrêté après le traitement des événements avant la mise en cache des niveaux 11-20. Cela signifie que ces niveaux ne seront pas enregistrés. À l'avenir, il est prévu d'ajouter une interface de chargement en arrière-plan au travailleur pour résoudre ce problème, ainsi que de télécharger des fichiers volumineux tels que des films.



Environ. Per.: Cette interface a été implémentée fin 2018 et s'appelait Background Fetch , mais jusqu'à présent elle ne fonctionne que dans Chrome et Opera (68% selon CanIUse ).



Lors de l'activation






Convient pour supprimer l'ancien cache et les migrations.



Après avoir installé un nouveau travailleur et arrêté l'ancien, le nouveau travailleur est activé et nous recevons un événement d'activation. C'est une excellente occasion de remplacer les ressources et de supprimer l'ancien cache.



self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys()
            .then(cacheNames => Promise.all(
                cacheNames.filter(cacheName => {
                    //  true, ,     ,
                    //  ,      
                }).map(cacheName => caches.delete(cacheName))
            ))
    )
})


Lors de l'activation, d'autres événements tels que la récupération sont mis en file d'attente, donc une longue activation pourrait théoriquement bloquer la page. N'utilisez donc cette étape que pour les choses que vous ne pouvez pas faire avec l'ancien travailleur.



Lorsqu'un événement personnalisé se produit






Convient lorsque l'ensemble du site ne peut pas être mis hors ligne. Dans ce cas, nous donnons à l'utilisateur la possibilité de décider quoi mettre en cache. Par exemple, une vidéo Youtube, une page Wikipédia ou une galerie d'images sur Flickr.



Donnez à l'utilisateur un bouton Lire plus tard ou Enregistrer. Lorsque vous cliquez sur le bouton, récupérez la ressource et écrivez-la dans le cache.



document.querySelector('.cache-article').addEventListener('click', event => {
    event.preventDefault()

    const id = event.target.dataset.id
    caches.open(`mysite-article ${id}`)
        .then(cache => fetch(`/get-article-urls?id=${id}`)
            .then(response => {
                // get-article-urls     JSON
                //  URL   
                return response.json()
            }).then(urls => cache.addAll(urls)))
})


L'interface de mise en cache est disponible sur la page, tout comme le worker lui-même, nous n'avons donc pas besoin d'appeler ce dernier pour économiser des ressources.



Lors de la réception d'une réponse






Convient aux ressources fréquemment mises à jour telles que la boîte aux lettres d'un utilisateur ou le contenu d'un article. Convient également aux contenus mineurs tels que les avatars, mais soyez prudent dans ce cas.



Si la ressource demandée n'est pas dans le cache, nous la récupérons du réseau, l'envoyons au client et l'écrivons dans le cache.



Si vous demandez plusieurs URL, telles que des chemins d'avatar, assurez-vous que cela ne déborde pas du magasin d'origine (origine - protocole, hôte et port) - si l'utilisateur a besoin de libérer de l'espace disque, vous ne devriez pas être le premier. Prenez soin de supprimer les ressources inutiles.



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


Pour utiliser efficacement la mémoire, nous ne lisons qu'une seule fois le corps de la réponse. L'exemple ci-dessus utilise la méthode clone pour créer une copie de la réponse. Ceci est fait afin d'envoyer simultanément une réponse au client et de l'écrire dans le cache.



Lors du contrôle de nouveauté






Convient pour la mise à jour des ressources qui ne nécessitent pas les dernières versions. Cela peut également s'appliquer aux avatars.



Si la ressource est dans le cache, nous l'utilisons, mais obtenons une mise à jour à la prochaine requête.



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


Lorsque vous recevez une notification push






L'API Push est une abstraction sur le worker. Il permet au travailleur de s'exécuter en réponse à un message du système d'exploitation. De plus, cela se produit quel que soit l'utilisateur (lorsque l'onglet du navigateur est fermé). Une page envoie généralement une demande à l'utilisateur pour l'autorisation d'effectuer certaines actions.



Convient pour le contenu qui dépend des notifications, tels que les messages de chat, les actualités du flux, les e-mails. Également utilisé pour synchroniser du contenu tel que des tâches dans une liste ou des coches dans un calendrier.



Le résultat est une notification qui, lorsqu'on clique dessus, ouvre la page correspondante. Cependant, il est très important de conserver les ressources avant d'envoyer la notification. L'utilisateur est en ligne lorsque la notification est reçue, mais il se peut qu'il soit hors ligne en cliquant dessus, il est donc important que le contenu soit disponible hors ligne à ce moment. L'application mobile Twitter le fait un peu mal.



Sans connexion réseau, Twitter ne fournit pas de contenu lié aux notifications. Cependant, cliquer sur la notification la supprime. Ne fais pas ça!



Le code suivant met à jour le cache avant d'envoyer la notification:



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

self.addEventListener('notificationclick', event => {
    if (event.notification.tag === 'new-email') {
        // ,   ,    /inbox/  ,
        // ,   
        new WindowClient('/inbox/')
    }
})


Avec synchronisation en arrière-plan






La synchronisation en arrière-plan est une autre abstraction sur le travailleur. Il vous permet de demander une synchronisation ponctuelle ou périodique des données d'arrière-plan. Il est également indépendant de l'utilisateur. Cependant, une demande d'autorisation lui est également adressée.



Convient pour la mise à jour de ressources insignifiantes, l'envoi régulier de notifications à propos desquelles sera trop fréquent et, par conséquent, ennuyeux pour l'utilisateur, par exemple, de nouveaux événements dans un réseau social ou de nouveaux articles dans le fil d'actualité.



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


Sauvegarde du cache



Votre source fournit une certaine quantité d'espace libre. Cet espace est partagé entre tous les stockages: local et de session, base de données indexée, système de fichiers et, bien sûr, cache.



Les tailles de stockage ne sont pas fixes et varient selon l'appareil et les conditions de stockage. Vous pouvez le vérifier comme ceci:



navigator.storageQuota.queryInfo('temporary').then(info => {
    console.log(info.quota)
    // : <  >
    console.log(info.usage)
    //  <    >
})


Lorsque la taille de tel ou tel stockage atteint la limite, ce stockage est effacé selon certaines règles qui ne peuvent pas être modifiées pour le moment.



Pour résoudre ce problème, l'interface d'envoi d'une demande d'autorisation (requestPersistent) a été proposée:



navigator.storage.requestPersistent().then(granted => {
    if (granted) {
        // ,       
    }
})


Bien sûr, l'utilisateur doit accorder l'autorisation pour cela. L'utilisateur doit faire partie de ce processus. Si la mémoire de l'appareil de l'utilisateur est pleine et que la suppression de données mineures ne résout pas le problème, l'utilisateur doit décider quelles données conserver et lesquelles supprimer.



Pour que cela fonctionne, le système d'exploitation doit traiter les magasins du navigateur comme des éléments distincts.



Répondre aux demandes



Peu importe le nombre de ressources que vous mettez en cache, le travailleur ne l'utilisera pas tant que vous ne lui aurez pas dit quand et quoi utiliser. Voici quelques modèles de traitement des demandes.



En espèces seulement






Convient à toutes les ressources statiques de la version actuelle de la page. Vous devez mettre ces ressources en cache pendant la phase de configuration des nœuds de calcul pour pouvoir les envoyer en réponse aux demandes.



self.addEventListener('fetch', event => {
    //     ,
    //      
    event.respondWith(caches.match(event.request))
})


Réseau uniquement






Convient aux ressources qui ne peuvent pas être mises en cache, telles que les données analytiques ou les demandes non GET.



self.addEventListener('fetch', event => {
    event.respondWith(fetch(event.request))
    //     event.respondWith
    //      
})


D'abord le cache, puis, en cas de panne, le réseau






Convient pour traiter la plupart des demandes dans les applications hors ligne.



self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})


Les ressources enregistrées sont renvoyées du cache, les ressources non enregistrées du réseau.



Celui qui avait le temps, il a mangé






Convient aux petites ressources à la recherche de meilleures performances pour les périphériques à faible mémoire.



La combinaison d'un ancien disque dur, d'un antivirus et d'une connexion Internet rapide peut accélérer la récupération des données du réseau par rapport à la récupération des données du cache. Cependant, récupérer des données sur le réseau pendant que les données sont stockées sur l'appareil de l'utilisateur est un gaspillage de ressources.



// Promise.race   ,   
//       .
//   
const promiseAny = promises => new Promise((resolve, reject) => {
    //  promises   
    promises = promises.map(p => Promise.resolve(p))
    //   ,    
    promises.forEach(p => p.then(resolve))
    //     ,   
    promises.reduce((a, b) => a.catch(() => b))
        .catch(() => reject(Error('  ')))
})

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


Environ. Lane: Vous pouvez maintenant utiliser Promise.allSettled à cet effet, mais le support de son navigateur est de 80%: -20% des utilisateurs, c'est probablement trop.



Réseau d'abord, puis, en cas de panne, cache






Convient pour les ressources fréquemment mises à jour et n'affectant pas la version actuelle du site, par exemple, des articles, des avatars, des fils d'actualité sur les réseaux sociaux, des notes de joueurs, etc.



Cela signifie que vous proposez du nouveau contenu aux utilisateurs en ligne et l'ancien contenu aux utilisateurs hors ligne. Si la demande d'une ressource du réseau réussit, le cache doit probablement être mis à jour.



Cette approche a un inconvénient. Si l'utilisateur a des problèmes de connexion ou est lent, il doit attendre que la demande se termine ou échoue au lieu de récupérer instantanément le contenu du cache. Cette attente peut être très longue, entraînant une expérience utilisateur terrible.



self.addEventListener('fetch', event => {
    event.respondWith(
        fetch(event.request).catch(() => caches.match(event.request))
    )
})


D'abord le cache, puis le réseau






Convient aux ressources fréquemment mises à jour.



Cela nécessite que la page envoie deux requêtes, une pour le cache et une pour le réseau. L'idée est de renvoyer les données du cache puis de les actualiser lors de la réception des données du réseau.



Parfois, vous pouvez remplacer les données actuelles lorsque vous en recevez de nouvelles (par exemple, le classement des joueurs), mais cela pose problème pour de gros éléments de contenu. Cela peut conduire à la disparition de ce que l'utilisateur lit ou interagit actuellement.



Twitter ajoute du nouveau contenu au-dessus du contenu existant tout en maintenant le défilement: l'utilisateur voit une notification de nouveaux tweets en haut de l'écran. Ceci est possible grâce à l'ordre linéaire du contenu. J'ai copié ce modèle pour afficher le contenu du cache le plus rapidement possible et ajouter le nouveau contenu à mesure qu'il provient du Web.



Code sur la page:



const networkDataReceived = false

startSpinner()

//   
const networkUpdate = fetch('/data.json')
    .then(response => response.json())
        .then(data => {
            networkDataReceived = true
            updatePage(data)
        })

//   
caches.match('/data.json')
    .then(response => {
        if (!response) throw Error(' ')
        return response.json()
    }).then(data => {
        //      
        if (!networkDataReceived) {
            updatePage(data)
        }
    }).catch(() => {
        //      ,  -   
        return networkUpdate
    }).catch(showErrorMessage).then(stopSpinner)


Code de travail:



nous accédons au réseau et mettons à jour le cache.



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


Filet de sécurité






Si les tentatives d'obtention de la ressource à partir du cache et du réseau échouent, il doit y avoir une solution de secours.



Convient aux espaces réservés (remplacement des images par des images factices), aux requêtes POST ayant échoué, aux pages "Non disponible hors connexion".



self.addEventListener('fetch', event => {
    event.respondWith(
        //     
        //   ,   
        caches.match(event.request)
            .then(response => response || fetch(event.request))
            .catch(() => {
                //    ,  
                return caches.match('/offline.html')
                //       
                //    URL   
            })
    )
})


Si votre page soumet un e-mail, le collaborateur peut l'enregistrer dans une base de données indexée avant de soumettre et notifier à la page que la soumission a échoué, mais l'e-mail a été enregistré.



Créer un balisage côté travailleur






Convient aux pages qui sont rendues côté serveur et ne peuvent pas être mises en cache.



Le rendu des pages côté serveur est un processus très rapide, mais il rend le stockage de contenu dynamique dans le cache inutile, car il peut être différent pour chaque rendu. Si votre page est contrôlée par un travailleur, vous pouvez demander des ressources et afficher la page directement à cet endroit.



import './templating-engine.js'

self.addEventListener('fetch', event => {
    const requestURL = new URL(event.request.url)

    event.respondWith(
        Promise.all([
            caches.match('/article-template.html')
                .then(response => response.text()),
            caches.match(`${requestURL.path}.json`)
                .then(response => response.json())
        ]).then(responses => {
            const template = responses[0]
            const data = responses[1]

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


Ensemble


Vous ne devez pas être limité à un modèle. Vous devrez probablement les combiner en fonction de la demande. Par exemple, Training-to-Thrill utilise les éléments suivants:



  • Mise en cache de la configuration des travailleurs pour les éléments persistants de l'interface utilisateur
  • Mise en cache sur la réponse du serveur pour les images et les données Flickr
  • Récupération des données du cache et en cas d'échec du réseau pour la plupart des demandes
  • Récupération des ressources du cache, puis du Web pour les résultats de recherche Flick


Il suffit de regarder la demande et de décider quoi en faire:



self.addEventListener('fetch', event => {
    //  URL
    const requestURL = new URL(event.request.url)

    //       
    if (requestURL.hostname === 'api.example.com') {
        event.respondWith(/*    */)
        return
    }

    //    
    if (requestURL.origin === location.origin) {
        //   
        if (/^\/article\//.test(requestURL.pathname)) {
            event.respondWith(/*    */)
            return
        }
        if (/\.webp$/.test(requestURL.pathname)) {
            event.respondWith(/*    */)
            return
        }
        if (request.method == 'POST') {
            event.respondWith(/*     */)
            return
        }
        if (/cheese/.test(requestURL.pathname)) {
            event.respondWith(
                // . .:    -   ?
                new Response('Flagrant cheese error', {
                //    
                status: 512
                })
            )
            return
        }
    }

    //  
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})


J'espère que l'article vous a été utile. Merci de votre attention.



All Articles