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 dinamici per risolverlo autonomamente. che ti offre il controllo sulla memorizzazione nella cache e su come vengono gestite le richieste. Ciò significa che puoi creare i tuoi pattern. Diamo un'occhiata ad alcuni possibili pattern in isolamento, ma in pratica è probabile che 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

Service Worker consente di gestire le richieste in modo indipendente dalla memorizzazione nella cache, quindi te le dimostrerò 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. Anche se ciò accade, qualsiasi versione precedente del Service worker è ancora in esecuzione e pubblica le pagine, quindi le operazioni eseguite qui non devono interromperlo.

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 del tutto non funzionante se non venissero recuperati, elementi che un'app equivalente per una piattaforma farà parte del 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.

È simile a quanto indicato sopra, 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 riportato sopra 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 della possibile assenza di questi livelli e riprovare a memorizzarli nella cache se mancano.

Il service worker può essere interrotto durante il download dei livelli 11-20, poiché ha finito di gestire gli eventi, quindi 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 Chromium fork.

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 quando la versione precedente era 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, una galleria particolare su Flickr.

Offrire all'utente un pulsante "Leggi più tardi" o "Salva per l'offline". Dopo aver 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 l'API cache direttamente dalla pagina.

Risposta sulla rete

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

Ideale per: l'aggiornamento frequente di risorse come la posta in arrivo di un utente o i contenuti degli articoli. È 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 la prima scelta. 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.

Inattivo-durante-riconvalida

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

Questo comportamento è molto simile al comando stale-while-revalidate di HTTP.

Sul messaggio push

Sul messaggio push.
Al messaggio push.

L'API Push è un'altra funzionalità basata su Service Worker. Ciò consente di riattivare il service worker in risposta a un messaggio dal 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. Chiedi l'autorizzazione per eseguire questa operazione da una pagina e all'utente verrà chiesto di farlo.

Ideale per: contenuti relativi a una notifica, ad esempio un messaggio di chat, una notizia dell'ultima ora 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 comune è una notifica che, quando viene toccata, apre/imposta una pagina pertinente, ma per la quale l'aggiornamento delle cache prima che ciò avvenga è estremamente importante. 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.
Sincronizzazione in background.

La sincronizzazione in background è un'altra funzionalità basata sul 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. Solo il service worker viene svegliato. 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. In questo caso, utilizza l'APIPermissions.

È 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 suo dispositivo è molto complesso 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 relative suddivisioni dell'utilizzo dello spazio di archiviazione, anziché segnalare il browser come un singolo elemento.

Suggerimenti per la pubblicazione: risposta alle richieste

Indipendentemente dalla quantità di memorizzazione nella cache, il service worker non utilizzerà la cache a meno che non glielo indichi quando e come. Ecco alcuni pattern per la gestione delle richieste:

Solo cache

Solo cache.
Solo cache.

Ideale per:tutto ciò 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:oggetti che non hanno equivalenti offline, come ping di Analytics e 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 spesso non devi gestire questo caso in modo specifico, Cache, il fallback alla rete copre la situazione.

Cache, con fallback alla rete

Cache, ricorrenza 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. Altri pattern saranno eccezioni in base alla richiesta in entrata.

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.
Cache e gara di rete.

Ideale per: piccoli asset in cui stai cercando di migliorare le prestazioni sui dispositivi con accesso lento al disco.

Con alcune combinazioni di dischi rigidi meno recenti, programmi di scansione antivirus e connessioni a internet più veloci, il recupero delle risorse dalla rete può essere più rapido che andare su 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.
Utilizzo della rete di riserva nella cache.

Ideale per: una correzione rapida per le risorse che si aggiornano spesso, 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 precedente. 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. Vedi il pattern successivo, Memorizza nella cache quindi rete, per una soluzione migliore.

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 è sufficiente sostituire i dati correnti quando arrivano nuovi dati (ad es. la classifica dei giochi), ma la situazione può essere causa di disturbo con contenuti più grandi. Fondamentalmente, non "scomparire" qualcosa che l'utente potrebbe aver letto o con cui potrebbe interagire.

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

Nell'ambito addestrato all'emozione ho risolto il problema utilizzando XHR invece del fetch, e abusando dell'intestazione Accept per indicare al service worker da dove recuperare il risultato (codice pagina, codice del 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 per le quali la 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',
        },
      });
    }),
  );
});

Organizzazione

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

Esamina la richiesta e decidi 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 come fare.

Crediti

... per le adorabili icone:

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

Per approfondire