Libro di ricette offline

Jake Archibald
Jake Archibald

Con Service Worker abbiamo rinunciato a cercare di risolvere il problema offline e abbiamo dato agli sviluppatori gli elementi necessari per risolverlo autonomamente. Ti consente di controllare la memorizzazione nella cache e la modalità di gestione delle richieste. Ciò significa che puoi creare i tuoi pattern. Diamo un'occhiata ad alcuni possibili pattern in isolamento, ma in pratica probabilmente ne utilizzerai molti in combinazione a seconda dell'URL e del contesto.

Per una demo funzionante di alcuni di questi pattern, consulta Addestrati per stupire e questo video che mostra l'impatto sul rendimento.

La cache machine: quando memorizzare le risorse

I Service Worker ti consentono di gestire le richieste indipendentemente dalla memorizzazione nella cache, quindi li illustrerò separatamente. Innanzitutto, la memorizzazione nella cache, quando deve essere eseguita?

Al momento dell'installazione, come dipendenza

Al momento dell'installazione, come dipendenza.
Al momento dell'installazione, come dipendenza.

Service Worker ti fornisce un evento install. Puoi utilizzarlo per preparare gli elementi che devono essere pronti prima di gestire altri eventi. Durante questo periodo, qualsiasi versione precedente del tuo Service Worker è ancora in esecuzione e pubblica pagine, pertanto le azioni che esegui qui non devono interrompere questa operazione.

Ideale per: CSS, immagini, caratteri, JS, modelli e praticamente qualsiasi elemento che consideri statico per quella "versione" del tuo sito.

Si tratta di elementi che renderebbero il tuo sito completamente non funzionale se non venissero recuperati, elementi che un'app specifica per la piattaforma equivalente includerebbe nel download iniziale.

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 accetta una promessa per definire la durata e il successo dell'installazione. Se la promessa viene rifiutata, l'installazione è considerata un errore e questo Service Worker verrà abbandonato (se è in esecuzione una versione precedente, verrà lasciata invariata). caches.open() e cache.addAll() restituiscono promesse. Se non è possibile recuperare una delle risorse, la chiamata cache.addAll() viene rifiutata.

Su trained-to-thrill lo uso per memorizzare nella cache le risorse statiche.

Al momento dell'installazione, non come dipendenza

Al momento dell'installazione, non come dipendenza.
All'installazione, non come dipendenza.

Questa opzione è simile a quella precedente, ma non ritarda il completamento dell'installazione e non ne causa il fallimento se la memorizzazione nella cache non va a buon fine.

Ideale per:risorse più grandi che non sono necessarie subito, ad esempio asset per i livelli successivi di un gioco.

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'esempio precedente non passa la promessa cache.addAll per i livelli 11-20 a event.waitUntil, quindi anche se non va a buon fine, il gioco sarà comunque disponibile offline. Naturalmente, dovrai tenere conto dell'eventuale assenza di questi livelli e riprovare a memorizzarli nella cache se mancano.

Il Service Worker potrebbe essere interrotto durante il download dei livelli 11-20 poiché ha terminato l'elaborazione degli eventi, il che significa che non verranno memorizzati nella cache. In futuro, l'API Web Periodic Background Sync gestirà casi come questo e download di dimensioni maggiori, come i film. Al momento, questa API è supportata solo su fork di Chromium.

All'attivazione

All'attivazione.
All'attivazione.

Ideale per:pulizia e migrazione.

Una volta installato un nuovo Service Worker e non viene utilizzata una versione precedente, il nuovo viene attivato e viene generato un evento activate. Poiché la versione precedente non è più disponibile, è un buon momento per gestire le migrazioni dello schema in IndexedDB ed eliminare le cache inutilizzate.

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);
         
}),
     
);
   
}),
 
);
});

Durante l'attivazione, altri eventi come fetch vengono inseriti in una coda, pertanto un'attivazione lunga potrebbe potenzialmente bloccare i caricamenti delle pagine. Mantieni l'attivazione il più semplice possibile e utilizzala solo per le attività che non potevi svolgere con la versione precedente attiva.

Su trained-to-thrill lo uso per rimuovere le vecchie cache.

All'interazione dell'utente

All'interazione dell'utente.
All'interazione dell'utente.

Ideale per:quando non è possibile mettere offline l'intero sito e hai scelto di consentire all'utente di selezionare i contenuti che vuole disponibili offline. Ad esempio, un video su YouTube, un articolo su Wikipedia o una determinata galleria su Flickr.

Offrire all'utente un pulsante "Leggi più tardi" o "Salva per l'offline". Quando viene fatto clic, recupera ciò che ti serve dalla rete e inseriscilo nella 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 è disponibile dalle pagine e dai service worker, il che significa che puoi aggiungere elementi alla cache direttamente dalla pagina.

Sulla risposta della rete

Sulla risposta della rete.
In base alla risposta della rete.

Ideale per:risorse che si aggiornano di frequente, come la posta in arrivo di un utente o i contenuti di un articolo. È utile anche per i contenuti non essenziali, come gli avatar, ma è necessario prestare attenzione.

Se una richiesta non corrisponde a nessuna nella cache, recuperala dalla rete, inviala alla pagina e allo stesso tempo aggiuingila alla cache.

Se esegui questa operazione per una serie di URL, ad esempio gli avatar, devi fare attenzione a non aumentare eccessivamente lo spazio di archiviazione della tua origine. Se l'utente deve recuperare spazio su disco, non vuoi essere il candidato principale. Assicurati di eliminare dalla cache gli elementi che non ti servono più.

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;
         
})
       
);
     
});
   
}),
 
);
});

Per consentire un utilizzo efficiente della memoria, puoi leggere il corpo di una risposta/richiesta una sola volta. Il codice sopra utilizza .clone() per creare copie aggiuntive che possono essere lette separatamente.

Su trained-to-thrill lo uso per memorizzare nella cache le immagini di Flickr.

Stale-while-revalidate

Stale-while-revalidate.
Stale-while-revalidate
.

Ideale per:risorse aggiornate di frequente per le quali non è essenziale avere la versione più recente. Gli avatar possono rientrare in questa categoria.

Se è disponibile una versione memorizzata nella cache, utilizzala, ma recupera un aggiornamento per la prossima volta.

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;
     
});
   
}),
 
);
});

È molto simile a stale-while-revalidate di HTTP.

Sul messaggio push

Sul messaggio push.
Al messaggio push.

L'API Push è un'altra funzionalità basata su Service Worker. In questo modo, il servizio worker può essere riattivato in risposta a un messaggio del servizio di messaggistica del sistema operativo. Questo accade anche quando l'utente non ha una scheda aperta sul tuo sito. Viene attivato solo il servizio worker. Richiedi l'autorizzazione per farlo da una pagina e l'utente verrà interpellato.

Ideale per: contenuti relativi a una notifica, ad esempio un messaggio di chat, una notizia di cronaca o un'email. Inoltre, i contenuti che cambiano di rado e che beneficiano della sincronizzazione immediata, ad esempio un aggiornamento della lista di cose da fare o una modifica del calendario.

Il risultato finale più comune è una notifica che, se toccata, apre/mette a fuoco una pagina pertinente, ma per la quale è estremamente importante aggiornare le cache prima che ciò accada. L'utente è ovviamente online al momento di ricevere il messaggio push, ma potrebbe non esserlo quando interagisce finalmente con la notifica, quindi è importante rendere questi contenuti disponibili offline.

Questo codice aggiorna le cache prima di mostrare una notifica:

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/');
 
}
});

Su sincronizzazione in background

Su sincronizzazione in background.
Sulla sincronizzazione in background.

La sincronizzazione in background è un'altra funzionalità basata su Service Worker. Ti consente di richiedere la sincronizzazione dei dati in background una tantum o con un intervallo (estremamente euristico). Questo accade anche quando l'utente non ha una scheda aperta sul tuo sito. Viene attivato solo il service worker. Richiedi l'autorizzazione per farlo da una pagina e l'utente verrà visualizzato.

Ideale per: aggiornamenti non urgenti, in particolare quelli che si verificano con una frequenza tale che un messaggio push per ogni aggiornamento sarebbe troppo frequente per gli utenti, ad esempio bacheche dei social o articoli di notizie.

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

Persistenza della cache

All'origine viene assegnata una certa quantità di spazio libero da utilizzare come preferisci. Lo spazio libero viene condiviso tra tutti gli spazi di archiviazione di origine: Spazio di archiviazione(locale), IndexedDB, Accesso al file system e, naturalmente, Cache.

L'importo che ricevi non è specificato. Varia a seconda del dispositivo e delle condizioni di stoccaggio. Puoi scoprire quanto hai tramite:

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.`);
}

Tuttavia, come per tutto lo spazio di archiviazione del browser, il browser è libero di eliminare i tuoi dati se lo spazio di archiviazione del dispositivo è in esaurimento. Purtroppo il browser non è in grado di distinguere i film che vuoi conservare a tutti i costi dal gioco che non ti interessa.

Per risolvere il problema, utilizza l'interfaccia 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.
});

Ovviamente, l'utente deve concedere l'autorizzazione. A tale scopo, utilizza l'API Permissions.

È importante che l'utente faccia parte di questo flusso, poiché ora possiamo aspettarci che abbia il controllo dell'eliminazione. Se lo spazio di archiviazione del dispositivo è insufficiente e la cancellazione dei dati non essenziali non risolve il problema, l'utente può decidere quali elementi conservare e rimuovere.

Affinché ciò funzioni, i sistemi operativi devono trattare le origini "durevoli" come equivalenti alle app specifiche della piattaforma nelle suddivisioni dell'utilizzo dello spazio di archiviazione, anziché segnalare il browser come un singolo elemento.

Suggerimenti per la pubblicazione: risposta alle richieste

Non importa quanto utilizzi la memorizzazione nella cache, il service worker non la utilizzerà a meno che tu non gli indichi quando e come. Ecco alcuni pattern per la gestione delle richieste:

Solo cache

Solo cache.
Solo cache.

Ideale per:qualsiasi elemento che consideri statico per una determinata "versione" del tuo sito. Dovresti averli memorizzati nella cache nell'evento di installazione, quindi puoi presumere che siano presenti.

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));
});

…anche se non è spesso necessario gestire questa richiesta in modo specifico, Cache, passaggio alla rete la copre.

Solo rete

Solo rete.
Solo rete.

Ideale per:elementi che non hanno un equivalente offline, come i ping di Analytics, le richieste non 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
});

…anche se non è spesso necessario gestire questa richiesta in modo specifico, Cache, passaggio alla rete la copre.

Cache, con fallback alla rete

Cache, con fallback alla rete.
Cache, con fallback alla rete.

Ideale per:creare app incentrate sull'esperienza offline. In questi casi, gestisci la maggior parte delle richieste in questo modo. Gli altri pattern saranno eccezioni in base alla richiesta in arrivo.

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

In questo modo, avrai il comportamento "solo cache" per gli elementi nella cache e il comportamento "solo rete" per tutti gli elementi non memorizzati nella cache (incluse tutte le richieste non GET, poiché non possono essere memorizzate nella cache).

Concorrenza tra cache e rete

Concorrenza tra cache e rete.
Gara tra cache e rete.

Ideale per:asset di piccole dimensioni in cui cerchi prestazioni su dispositivi con accesso al disco lento.

Con alcune combinazioni di hard disk meno recenti, scanner antivirus e connessioni a internet più veloci, recuperare le risorse dalla rete può essere più veloce che accedere al disco. Tuttavia, se l'utente ha i contenuti sul proprio dispositivo, accedere alla rete può comportare uno spreco di dati, quindi tieni presente questo aspetto.

// 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)]));
});

Rete che torna alla cache

La rete torna alla cache.
La rete torna alla cache.

Ideale per:una soluzione rapida per le risorse che si aggiornano di frequente, al di fuori della "versione" del sito. Ad esempio articoli, avatar, bacheche dei social media e classifiche dei giochi.

Ciò significa che offri agli utenti online i contenuti più aggiornati, mentre gli utenti offline ricevono una versione memorizzata nella cache meno recente. Se la richiesta di rete va a buon fine, molto probabilmente vorrai aggiornare la voce della cache.

Tuttavia, questo metodo presenta dei difetti. Se la connessione dell'utente è intermittente o lenta, dovrà attendere che la rete non sia più disponibile prima di poter visualizzare i contenuti perfettamente accettabili già presenti sul suo dispositivo. Questa operazione può richiedere molto tempo e risulta frustrante per l'utente. Per una soluzione migliore, consulta il pattern successivo, Cache e poi rete.

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

Cache e poi rete

Cache e poi rete.
Cache e poi rete.

Ideale per: contenuti che vengono aggiornati di frequente. Ad esempio, articoli, bacheche dei social media e classifiche dei giochi.

Ciò richiede che la pagina effettui due richieste, una alla cache e una alla rete. L'idea è mostrare prima i dati memorizzati nella cache, quindi aggiornare la pagina quando/se arrivano i dati di rete.

A volte puoi semplicemente sostituire i dati attuali quando arrivano nuovi dati (ad es. la classifica di un gioco), ma questo può causare interruzioni con contenuti più grandi. In sostanza, non "far scomparire " qualcosa con cui l'utente potrebbe interagire o che potrebbe essere in fase di lettura.

Twitter aggiunge i nuovi contenuti sopra i vecchi e regola la posizione di scorrimento in modo che l'utente non venga interrotto. Questo è possibile perché Twitter mantiene per lo più un ordine lineare dei contenuti. Ho copiato questo modello per trained-to-thrill per mostrare i contenuti sullo schermo il più rapidamente possibile, nonché per mostrare contenuti aggiornati non appena arrivano.

Codice nella pagina:

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);

Codice nel service worker:

Devi sempre accedere alla rete e aggiornare una cache man mano che procedi.

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;
     
});
   
}),
 
);
});

In trained-to-thrill ho risolto il problema utilizzando XHR anziché fetch, e abusando dell'intestazione Accept per indicare al service worker da dove recuperare il risultato (codice pagina, codice service worker).

Valore alternativo generico

Valore generico di riserva.
Valore alternativo generico
.

Se non riesci a pubblicare qualcosa dalla cache e/o dalla rete, ti consigliamo di fornire un valore alternativo generico.

Ideale per: immagini secondarie come avatar, richieste POST non riuscite e una pagina "Non disponibile offline".

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'elemento di riserva è probabilmente una dipendenza di installazione.

Se la tua pagina pubblica un'email, il tuo worker di servizio potrebbe ricorrere allo stoccaggio dell'email in una "Posta in uscita" di IndexedDB e rispondere comunicando alla pagina che l'invio non è riuscito, ma che i dati sono stati conservati correttamente.

Modelli lato service worker

Modelli lato ServiceWorker.
Modelli lato ServiceWorker
.

Ideale per:pagine la cui risposta del server non può essere memorizzata nella cache.

Il rendering delle pagine sul server consente di velocizzare le operazioni, ma può comportare l'inclusione di dati di stato che potrebbero non avere senso in una cache, ad esempio "Accesso eseguito come…". Se la tua pagina è controllata da un service worker, puoi scegliere di richiedere dati JSON insieme a un modello e di eseguire il rendering di quest'ultimo.

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',
       
},
     
});
   
}),
 
);
});

Riepilogo

Non sei limitato a uno di questi metodi. In effetti, probabilmente ne utilizzerai molti a seconda dell'URL della richiesta. Ad esempio, trained-to-thrill utilizza:

Basta esaminare la richiesta e decidere cosa fare:

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);
   
}),
 
);
});

…hai capito.

Crediti

…per le bellissime icone:

E grazie a Jeff Posnick per aver rilevato molti errori gravi prima che io premessi "Pubblica".

Per approfondire