Libro di ricette offline

Jake Archibald
Jake Archibald

Con Service Worker abbiamo rinunciato a risolvere problemi offline e abbiamo dato agli sviluppatori le parti mobili per risolverli autonomamente. Ti offre il controllo sulla memorizzazione nella cache e sulla gestione delle richieste. Ciò significa che puoi creare i tuoi modelli. Diamo un'occhiata ad alcuni possibili pattern isolati, ma in pratica probabilmente ne utilizzerai molti in tandem, a seconda dell'URL e del contesto.

Per una demo funzionante di alcuni di questi pattern, guarda Addestrato al brivido e questo video che mostra l'impatto del rendimento.

La macchina cache: quando archiviare le risorse

Service Worker consente di gestire le richieste in modo indipendente dalla memorizzazione nella cache, quindi le mostrerò 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.

Il service worker ti fornisce un evento install. Puoi usare questa funzionalità per preparare cose che devono essere pronte prima di gestire altri eventi. Anche se ciò accade qualsiasi versione precedente di Service Worker è ancora in esecuzione e pubblica pagine, le operazioni che esegui qui non devono interrompere la situazione.

Ideale per: CSS, immagini, caratteri, JS, modelli... praticamente tutto ciò che consideri statico in quella "versione" del sito.

Si tratta di elementi che renderebbero il tuo sito completamente non funzionale se non venissero recuperati, ovvero gli elementi che un'app equivalente specifica per la piattaforma fa 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 si impegna a definire la durata e il successo dell'installazione. Se la promessa viene rifiutata, l'installazione viene considerata non riuscita e questo service worker verrà abbandonato (se è in esecuzione una versione precedente, verrà lasciata intatta). caches.open() e cache.addAll() hanno promesso di reso. Se il recupero di una qualsiasi delle risorse non riesce, la chiamata cache.addAll() viene rifiutata.

Nell'addestrato per thrill lo uso per memorizzare nella cache gli asset statici.

Al momento dell'installazione, non come dipendenza

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

Si tratta di una procedura simile a quella riportata sopra, ma non ritarda il completamento dell'installazione e non determina la mancata riuscita dell'installazione se la memorizzazione nella cache non va a buon fine.

Ideale per: risorse più grandi che non sono immediatamente necessarie, 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 rispetta la promessa cache.addAll per i livelli 11-20 a event.waitUntil, quindi, anche se non funziona, il gioco sarà comunque disponibile offline. Naturalmente, dovrai soddisfare la possibile assenza di questi livelli e tentare nuovamente di memorizzarli nella cache se mancano.

Il service worker può essere interrotto durante il download dei livelli 11-20, poiché ha terminato di gestire gli eventi, il che significa che non verranno memorizzati nella cache. In futuro, l'API Web Periodic Background Synchronization gestirà casi come questo e download di dimensioni maggiori come i filmati. Quell'API è attualmente supportata solo sulle fork di Chromium.

All'attivazione

Al momento dell'attivazione.
All'attivazione.

Ideale per:pulizia e migrazione.

Una volta installato un nuovo Service Worker e una versione precedente non viene utilizzata, la nuova versione viene attivata e viene generato un evento activate. Poiché la versione precedente è obsoleta, è un buon momento per gestire le migrazioni di schemi 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 messi in coda, quindi un'attivazione lunga potrebbe bloccare potenzialmente i caricamenti delle pagine. Mantieni la tua attivazione il più snella possibile e utilizzala solo per cose che non potevi fare mentre era attiva la versione precedente.

Nell'addestrato per thrill lo uso per rimuovere le vecchie cache.

Al momento dell'interazione dell'utente

Al momento dell'interazione dell'utente.
All'interazione dell'utente.

Ideale per: quando l'intero sito non può essere messo offline e hai scelto di consentire all'utente di selezionare i contenuti che desidera rendere disponibili offline. un video su qualcosa del tipo YouTube, un articolo su Wikipedia, una galleria specifica su Flickr.

Assegna all'utente un pulsante "Leggi più tardi" o "Salva per offline". Dopo il 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 cache è disponibile sia dalle pagine sia dai service worker, il che significa che puoi aggiungerla alla cache direttamente dalla pagina.

Alla risposta di rete

Alla risposta di rete.
Alla risposta di rete.

Ideale per: aggiornare frequentemente 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 è necessaria la cura.

Se una richiesta non corrisponde ad alcun elemento nella cache, recuperala dalla rete, inviala alla pagina e, contemporaneamente, aggiungila alla cache.

Se esegui questa operazione per una serie di URL, ad esempio gli avatar, dovrai fare attenzione a non esaurire lo spazio di archiviazione dell'origine. Se l'utente ha bisogno di liberare spazio su disco, non vuoi essere il candidato principale. Assicurati di eliminare gli elementi contenuti nella cache 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 riportato sopra utilizza .clone() per creare copie aggiuntive che possono essere lette separatamente.

Nel campo training-to-thrill lo uso per memorizzare nella cache le immagini di Flickr.

Riconvalida in caso di inattività

Riconvalida in stato inattivo.
Riconvalida-mentre-inattiva.

Ideale per: aggiornare spesso risorse in cui avere la versione più recente non è essenziale. 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.

Al momento del messaggio push

Sul messaggio push.
Nel messaggio push.

L'API Push è un'altra funzionalità basata su Service Worker. Ciò consente al service worker di 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. Solo il service Worker viene svegliato. Chiedi l'autorizzazione a eseguire questa operazione da una pagina e all'utente viene chiesto.

Ideale per: contenuti correlati a una notifica, ad esempio un messaggio di chat, una notizia dell'ultima ora o un'email. Inoltre, vengono modificati raramente contenuti che sfruttano la sincronizzazione immediata, come l'aggiornamento di un elenco di cose da fare o l'alterazione di un 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ò accada è extremely importante. L'utente è ovviamente online quando riceve il messaggio push, ma potrebbe non esserlo quando interagisce con la notifica, per cui è 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/');
  }
});

Attiva la sincronizzazione in background

Sincronizzazione in background.
Sincronizzazione in background.

La sincronizzazione in background è un'altra funzionalità basata su Service Worker. Consente di richiedere la sincronizzazione dei dati in background come una tantum o in base a un intervallo (estremamente euristico). Questo accade anche quando l'utente non ha una scheda aperta sul tuo sito. Solo il service worker è stato svegliato. Richiedi l'autorizzazione per eseguire questa operazione da una pagina e all'utente verrà chiesto.

Ideale per: aggiornamenti non urgenti, in particolare quelli che si verificano talmente regolarmente che un messaggio push di ogni aggiornamento sarebbe troppo frequente per gli utenti, ad esempio cronologie social o articoli.

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

Alla tua origine viene data una certa quantità di spazio libero per fare ciò che vuole. Questo spazio libero viene condiviso tra tutti gli archivi di origine: Storage(locale), IndexedDB, Accesso al file system e, ovviamente, le cache.

L'importo ottenuto non è specificato. Varia in base alle condizioni del dispositivo e dello spazio di archiviazione. Puoi scoprire quanto hai guadagnato tramite:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

Tuttavia, come per lo spazio di archiviazione del browser, il browser è libero di eliminare i tuoi dati se il dispositivo è sottoposto a pressione di archiviazione. Sfortunatamente, il browser non è in grado di distinguere a tutti i costi i film che vuoi conservare e il 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.
});

Naturalmente, l'utente deve concedere l'autorizzazione. Per farlo, utilizza l'API Permissions.

Rendere l'utente parte di questa procedura è importante, poiché ora possiamo aspettarci che abbia il controllo dell'eliminazione. Se il dispositivo è sottoposto a pressione di archiviazione e la cancellazione dei dati non essenziali non risolve il problema, l'utente può decidere quali elementi conservare e rimuovere.

Affinché funzioni, richiede che i sistemi operativi trattino 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.

Pubblicazione dei suggerimenti: risposta alle richieste

Indipendentemente dalla quantità di memorizzazione nella cache, il service worker non utilizzerà la cache a meno che tu non gli comunichi 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 durante l'evento di installazione, così puoi essere certo 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 spesso non devi gestire in modo specifico questo caso, Cache, ripiegando sulla rete lo copre.

Solo rete

Solo rete.
Solo rete.

Ideale per: cose che non hanno equivalenti offline, come ping di analisi 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 in modo specifico questo caso, Cache, ripiegando sulla rete lo copre.

Cache, passaggio alla rete

Cache, con un nuovo passaggio alla rete.
Cache, passaggio alla rete.

Ideale per: creare soluzioni offline. In questi casi, è la modalità di gestione della maggior parte delle richieste. 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 ottieni il comportamento "solo cache" per gli elementi contenuti 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).

Gara di cache e rete

Cache e gara di rete.
Gare su cache e rete.

Ideale per: asset di piccole dimensioni per cui stai migliorando prestazioni sui dispositivi con accesso lento al disco.

Con alcune combinazioni di dischi rigidi meno recenti, scanner antivirus e connessioni a internet più veloci, ottenere le risorse dalla rete può essere più veloce che andare su disco. Tuttavia, visitare la rete quando l'utente ha i contenuti sul dispositivo può essere uno spreco di dati, 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)]));
});

Errore di rete nella cache

Errore di rete nella cache.
Impossibile recuperare la rete nella cache.

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

In questo modo, offri agli utenti online i contenuti più aggiornati, mentre gli utenti offline ricevono una versione precedente memorizzata nella cache. Se la richiesta di rete ha esito positivo, molto probabilmente vorrai aggiornare la voce della cache.

Tuttavia, questo metodo presenta dei problemi. Se l'utente ha una connessione lenta o intermittente, dovrà attendere che la rete non riesca prima di poter ricevere i contenuti perfettamente accettabili già sul suo dispositivo. Questa operazione può richiedere molto tempo ed è un'esperienza utente frustrante. Per una soluzione migliore, vedi il pattern successivo, Cache, quindi rete.

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

Cache, poi rete

Cache, quindi di rete.
Cache quindi alla rete.

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

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 e se arrivano i dati di rete.

A volte è possibile semplicemente sostituire i dati attuali quando arrivano nuovi dati (ad es. la classifica del gioco), ma ciò può creare interruzioni con contenuti più grandi. Fondamentalmente, non "nascondere " qualcosa che l'utente sta leggendo o con cui potrebbe interagire.

Twitter aggiunge i nuovi contenuti sopra quelli precedenti e regola la posizione di scorrimento in modo che l'utente rimanga senza interruzioni. Ciò è possibile perché Twitter mantiene per lo più un ordine prevalentemente lineare per i contenuti. Ho copiato questo pattern per addestrato per il brivido affinché i contenuti vengano visualizzati sullo schermo il più rapidamente possibile, mostrando al contempo 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:

Dovresti sempre accedere alla rete e aggiornare la 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;
      });
    }),
  );
});

Nella fase addestrato per il thrill, ho risolto questo problema utilizzando XHR anziché fetch, e utilizzando in modo illecito l'intestazione Accept per indicare al service worker da dove ottenere il risultato (codice pagina, codice del service worker).

Di riserva generico

Di riserva generico.
Riserva generica.

Se non riesci a fornire qualcosa dalla cache e/o dalla rete, puoi fornire un modello di riserva generico.

Ideale per: immagini secondarie come avatar, richieste POST non riuscite e una pagina "Non disponibile in modalità 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.
      }),
  );
});

È probabile che l'elemento di riserva a cui esegui l'operazione sia una dipendenza di installazione.

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

Modelli lato 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 velocizza le cose, ma questo può significare includere dati di stato che potrebbero non avere senso in una cache, ad esempio "Accesso eseguito come...". Se la pagina è controllata da un service worker, puoi scegliere di richiedere dati JSON insieme a un modello ed eseguire il rendering.

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

Elaborazione

Non devi limitarti a uno di questi metodi. Anzi, probabilmente ne utilizzerai molti, a seconda dell'URL della richiesta. Ad esempio, la parola addestrato per entusiasmare 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 bene.

Crediti

... per le bellissime icone:

Grazie a Jeff Posnick per aver individuato molti errori di urlo prima di premere "pubblica".

Per approfondire