Categorie: Guide

Guida completa ai Service Worker Javascript

Fino ad oggi ci siamo soffermati a discutere di performance web in merito alla prima visita. Naturalmente nel ciclo di navigazione lo stesso utente ritornerà più volte a visitare il sito web.

Oggi vedremo come ottenere elevatissimi livelli di performance e tempi di caricamento molto rapidi utilizzando la tecnica dei service worker, parte integrante delle ormai famose Progressive Web Application.

Tale tecnica mira a rendere le visite successive alla prima molto veloci, al punto che il tempo di risposta della pagina può essere reso istantaneo quasi al pari di quello di un’app nativa. 

Cos’è un service worker

Un service worker è uno script Javascript che il browser avvia in background. Esso è separato dalla pagina pertanto non può modificarne gli elementi come i normali script ma può comunicare con essi mediante “messaggi” scambiati mediante window.postMessage().

La cosa interessante in termini di performance è che il service worker è anteposto tra il browser e la rete. Esattamente come un proxy è in grado di intercettare richieste a pagine web e file statici e rispondere secondo politiche che siamo noi stessi a decidere.

Pagina web richiesta senza service worker

Pagina web richiesta con service worker

Grazie ai service worker siamo noi a pilotare il funzionamento della cache, il vero vantaggio rispetto all’utilizzo della normale browser cache.

Potremmo inoltre decidere di effettuare anche cache del codice HTML, cosa impossibile con la normale browser cache, velocizzando di moltissimo il tempo di caricamento della pagina. 

Conservando in cache una copia del contenuto della pagina è possibile rendere la navigazione offline una realtà concreta, trasformando di fatti un sito web in una sorta di applicazione mobile.

Le possibilità e le combinazioni strategiche sono moltissime e sviluppare le giuste strategie in funzione del contesto può rendere l’esperienza di navigazione molto gradevole, special modo se da mobile.

Prerequisiti

Un service worker può essere avviato in qualunque contesto purché rispetti i seguenti requisiti:

  • Supporto HTTPS: sebbene sia possibile effettuare test in locale utilizzando HTTP, in produzione è obbligatorio disporre di un certificato SSL. Questa è una misura di sicurezza per evitare attacchi di tipo man in the middle.
  • Browser supportato: recenti versioni di Google Chrome, Firefox e Opera supportano pienamente i service worker. Microsoft si sta accingendo ad aggiungere il supporto nelle prossime versioni di Internet Explorer.

Casi reali di utilizzo di service worker

I service worker, in quanto motore delle Progressive Web Application, sono largamente utilizzati da grosse realtà per migliorare significativamente l’esperienza utente.

AliExpress ha aumentato il tasso di conversione dell’82% sui dispositivi iOS. Inoltre ha raddoppiato il numero di pagine visitate per singola sessione su tutti i browser supportati.

Flipkart, il più grande e-commerce indiano, ha aumentato a 3.5 minuti il tempo medio di permanenza degli utenti mobile che prima si attestava a 70 secondi. Il numero di pagine visitate per sessione è stato triplicato con un conseguente incremento del tasso di conversione del 70%!

Ciclo di vita di un service worker

Il ciclo di vita di un service worker è la parte più complessa ma la sua comprensione è di fondamentale importanza per non commettere errori di natura logica durante lo sviluppo del software. 

Una volta compreso per bene il ciclo di vita sarà possibile realizzare service worker solidi, con una struttura robusta agli aggiornamenti ed a supporto del core business del sito web.

Il ciclo di vita di un service worker è composto da quattro fasi:

  1. Registrazione: il service worker viene scaricato dal browser, analizzato ed eseguito;
  2. Installazione: il service worker viene installato;
  3. Attivazione: il service worker è pronto ed è in grado di poter controllare gli eventi generati dal client;
  4. Fetch: il service worker è in grado di intercettare le richieste e rispondere secondo le opportune strategie di caching.

Fetch non è altro che un evento generato dal client. Vi sono diversi altri eventi che il service worker può intercettare come ad esempio l’evento push per generare le famose notifiche push oppure l’evento message per scambiare messaggi con script Javascript che girano in contesti browser differenti.

Nel nostro caso l’evento fetch è quello che più ci interessa e quello su cui lavoreremo. Ultimo accorgimento, ma non per importanza, è che i service worker utilizzano gli oggetti Promise per poter eseguire operazioni in modalità asincrona e quindi non bloccanti.

Registrazione

Come prima cosa bisogna comunicare al browser l’esistenza di un service worker all’interno del sito web. Per farlo basta inserire su tutte le pagine del sito uno script come il seguente.

<script>
if ('serviceWorker' in navigator) {
 
 // Path che contiene il service worker
 navigator.serviceWorker.register('/service-worker.js').then(function(registration) {
   console.log('Service worker installato correttamente, ecco lo scope:', registration.scope);
 }).catch(function(error) {
   console.log('Installazione service worker fallita:', error);
 });
 
}
</script>

Il codice inizia controllando il supporto da parte del browser verificando la presenza di navigator.serviceWorker. Se supportato il service worker viene registrato per mezzo di navigator.serviceWorker.register che restituisce un oggetto Promise il quale si risolve con successo a registrazione avvenuta correttamente.

service-worker.js è il file Javascript residente nella root del sito web e che contiene il codice del service worker. Di seguito il contenuto:

// Evento install
self.addEventListener('install', event => {
 
 // Codice da eseguire su installazione
 console.log("Service Worker Installato");

});


// Evento activate 
self.addEventListener('activate', event => {

 // Codice da eseguire su attivazione 
 console.log("Service Worker Attivo");
 
});


// Evento fetch
self.addEventListener('fetch', event => {
 
 // Codice da eseguire su fetch di risorse
 console.log("Richiesta URL: "+event.request.url);

});

Al momento il service worker non fa nulla di interessante, viene semplicemente installato e ad ogni richiesta stampa in console un messaggio con la URL che il browser tenta di scaricare dal server web.

Per verificare la corretta registrazione del service worker basterà aprire la DevTools di Google Chrome e cliccare la voce Service Worker presente all’interno del tab Application.

Scope del service worker

Il raggio d’azione del service worker, lo scope, viene monitorato con registration.scope. Lo scope del service worker determina quali file il service worker sarà in grado di controllare, ovvero per quale path il service worker sarà in grado di intercettare le richieste.

Lo scope di default è il path in cui risiede il file del service worker. Se il service worker risiede nella root directory del sito web sarà in grado di controllare tutte le richieste dell’intero dominio. È possibile definire arbitrariamente un path di scope come secondo parametro di navigator.serviceWorker.register:

navigator.serviceWorker.register('/service-worker.js', {
 scope: '/app/'
});

In questo caso lo scope del service worker viene cambiato in /app/, ciò sta a significare che il service worker sarà in grado di intercettare richieste come /app/, /app/style.css, /app/js/jquery.js, ecc.. ma non richieste a file residenti in directory di livello superiore come /app, /, /style.css, /readme.html, ecc..

Installazione

A registrazione service worker completata, il browser ne tenta l’installazione. Ciò si verifica quando il service worker viene considerato nuovo, il che avviene solo in due casi:

  1. Il sito non possiede attualmente alcun service worker;
  2. Esiste una reale differenza in termini di byte tra il nuovo service worker e quello precedentemente installato.

Conseguentemente all’installazione viene richiamato l’evento install. Tale evento consente di effettuare il precaching, ovvero inserire in cache pagine e file statici del sito web prima di intercettarne le richieste. Per farlo occorre utilizzare gli oggetti Promise event e cache come segue:

‘use strict’;

// Array di configurazione del service worker
var config = {

version: ‘versionesw1::’,

// Risorse da inserire in cache immediatamente - Precaching
staticCacheItems: [
  ‘/wp-includes/js/jquery/jquery.js’,
  ‘/wp-content/themes/miotema/logo.png’,
  ‘/wp-content/themes/miotema/fonts/opensans.woff’,
  ‘/wp-content/themes/miotema/fonts/fontawesome-webfont.woff2’,
],

};

// Funzione che restituisce una stringa da utilizzare come chiave per la cache
function cacheName (key, opts) {
 
 return `${opts.version}${key}`;
 
}

// Evento install
self.addEventListener('install', event => {
 
 event.waitUntil(
 
   // Inserisco in cache le URL configurate in config.staticCacheItems
   caches.open( cacheName('static', config) ).then(cache => cache.addAll(config.staticCacheItems))
 
   // self.skipWaiting() evita l'attesa, il che significa che il service worker si attiverà immediatamente non appena conclusa l'installazione
   .then( () => self.skipWaiting() ) 
 
 );
 
 console.log("Service Worker Installato");

});

Se si decidesse di aggiungere/eliminare nuove risorse da inserire in cache, bisognerà avere l’accortezza di cambiare il nome della versione del service worker ed eliminare dalla cache le risorse già presenti, come vedremo nel prossimo paragrafo.

Una cosa molto importante da sapere è che le risorse da inserire in cache in fase di precaching devono esistere realmente sul server web altrimenti il service worker genererà un errore fatale e l’installazione non andrà a buon fine. 

Il metodo skipWaiting() consente al service worker di passare allo stato di attivazione ad installazione conclusa e quindi essere subito operativo.

Attivazione

Una volta installato, il service worker passa nello stato di attivazione. Se la pagina al momento è controllata da un altro service worker, l’attuale passa in uno stato di attesa per poi diventare operativo al prossimo caricamento di pagina, quando il vecchio service worker viene sostituito.

Questo per essere sicuri che solo un service worker (o una sola versione di service worker) per volta possa essere eseguito nello stesso contesto temporale.

A service worker attivato, viene richiamato l’evento activate, l’evento ideale per svuotare la cache obsoleta dell’eventuale precedente versione di service worker. Dopodiché il service worker sarà in grado di effettuare fetching di risorse o restare in attesa di altri eventi.

Di default il nuovo service worker diventa operativo al refresh della pagina o dopo aver richiamato il metodo clients.claim(). Fino a quel momento le eventuali richieste non saranno intercettate. 

Di seguito un codice di esempio eseguito al verificarsi dell’evento activate. Il codice effettua il purge della cache di versioni del service worker diverse dall’attuale e successivamente  richiama clients.claim() per poter intercettare le richieste fin da subito.

self.addEventListener('activate', event => {
 
 // Questa funzione elimina dalla cache tutte le risorse la cui chiave non contiene il nome della versione
 // impostata sul config di questo service worker
 function clearCacheIfDifferent(event, opts) {
 
   return caches.keys().then(cacheKeys => {
     var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0);
     var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey));
     return Promise.all(deletePromises);
   });
 
 }

event.waitUntil(
 
 // Se la versione del service worker cambia, svuoto la cache
 clearCacheIfDifferent(event, config)
 
 // Con self.clients.claim() consento al service worker di poter intercettare le richieste (fetch) fin da subito piuttosto che attendere il refresh della pagina
 .then( () => self.clients.claim() )
 
 );
 
 console.log("Service Worker Avviato");
 
});

 

Fetch

Grazie all’evento fetch il service worker potrà agire da proxy tra l’applicazione web e la rete.

Il service worker intercetterà ogni richiesta HTTP del browser e sarà in grado di rispondere a quest’ultimo prendendo la risorsa dalla cache piuttosto che scaricarla dalla rete o applicando le più disparate strategie di caching.

Grazie all’evento fetch il service worker diventa un vero e proprio strumento per migliorare le performance di caricamento di un sito web.

Strategie di caching

Diverse sono le strategie che possono essere adottare per migliorare le performance di un sito web mediante i service worker. A seconda del contesto e del modello di business del sito è possibile adottare una strategia piuttosto che l’altra.

È importante sottolineare che il service worker non utilizza cache a meno che non siamo noi a dirlo, quindi di default il comportamento nella fase di fetch delle risorse sarà quello nativo del browser.

Di seguito l’elenco completo delle strategie con esempi di codice di implementazione. 

Network First

Chiamata anche network, falling back to cache, questa strategia mira ad avere un contenuto sempre fresco scaricandolo dalla rete, fornendo la copia in cache solo in caso di problemi di connettività (ad esempio in caso di connessione offline).

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

Una modifica interessante in questo caso potrebbe essere quella di aggiornare la copia in cache quando la risorsa viene scaricata dalla rete, cosicché in caso di errori di connessione viene restituita la copia più giovane.

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

Cache First

Chiamata anche cache, falling back to network, questa strategia verifica se la risorsa è disponibile in cache. Se così fosse viene restituita la copia in cache. In caso contrario la risorsa viene scaricata dalla rete.

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

Network Only

È la strategia più banale in quanto viene simulato il normale comportamento del browser, ovvero scaricare le risorse direttamente dalla rete.

Per applicare  questa strategia basta non inserire alcuna riga di codice all’interno dell’evento fetch:

self.addEventListener('fetch', function(event) {});

o al limite inserire semplicemente la seguente riga:

self.addEventListener('fetch', function(event) {
    event.respondWith(fetch(event.request));
});

Cache Only

Esattamente opposta alla strategia network only, in questo caso il service worker risponde solo con elementi conservati in cache. In caso di MISS la risposta restituita al browser simulerà l’errore di connessione.

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

Fastest

Questa strategia mira a fornire all’utente la risposta più veloce. Il service worker avvia contemporaneamente una richiesta in cache ed una in rete. La prima che risponde verrà restituita all’utente.

Questa soluzione può essere l’ideale per quei dispositivi con vecchi hard drive dove la lettura da disco può addirittura rivelarsi più lenta del fetch dalla rete. Per i dispositivi moderni è meglio utilizzare la strategia cache then network.

Siccome il service worker può ritornare un solo Promise, occorre realizzare una funzione a cui passare un array di oggetti Promise, in questo caso cache e fetch, e risolverli quasi contemporaneamente ritornando quello che si risolve per primo.

function promiseAny(promises) {

    return new Promise((resolve, reject) => {
        promises = promises.map(p => Promise.resolve(p));
        promises.forEach(p => p.then(resolve));
        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)])
    );

});

Cache then network

Questa strategia mira a fornire il contenuto dalla cache per una risposta molto rapida. Dopodiché in parallelo si avvia una richiesta in rete per scaricare una copia aggiornata della risorsa e sostituirla con quella in cache. La risorsa ricevuta dalla rete viene poi sostituita con quella presente sulla pagina.

Per ottenere questo obiettivo occorre avere sia codice lato pagina che lato service worker. Questo perché il service worker deve rispondere subito e non può attendere il completamento di un secondo task senza rallentare l’intera operazione.

Per ottenere qualcosa di analogo usando il solo service worker occorre utilizzare postMessage affinché la pagina comunichi al service worker la risorsa da interpellare con un secondo fetch, sia esso dalla cache o dalla rete. La complessità rimane uguale ma molto utile in caso si utilizzi il service worker per fare page caching.

Di seguito il codice da inserire nella pagina:

var networkDataReceived = false;
var risorsa = '/data.json';

startSpinner();

// Richiesta di rete (intercettata dal service worker)
var networkUpdate = fetch(risorsa).then(function(response) {
    return response.json();
}).then(function(data) {
    networkDataReceived = true;
    updatePage();
});

// Richiesta dalla cache
caches.match(risorsa).then(function(response) {
    if (!response) throw Error("No data");
    return response.json();
}).then(function(data) {
    // Aggiorno la pagina se il contenuto dalla rete non e' stato ancora ricevuto
    if (!networkDataReceived) {
      updatePage(data);
    }
}).catch(function() {
    // Contenuto non presente in cache, attendo quello dalla rete
    return networkUpdate;
}).catch(showErrorMessage).then(stopSpinner);

Di seguito quello del service worker:

self.addEventListener('fetch', function(event) {
    event.respondWith(
        fetch(event.request).then(function(response) {
            cache.put(event.request, response.clone());
            return response;
        })
    );
});

Varie ed eventuali

Esempio di service worker

'use strict';

var config = {
  
  version: 'versione1::',
  
  precachingItems: [
    '/jquery.js',
    '/style.css',
  ],
    
  blacklistCacheItems: [
    '/service-worker.js'
  ],
  
 offlineImage: '<svg role="img" aria-labelledby="offline-title"'
 + ' viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">'
 + '<title id="offline-title">Offline</title>'
 + '<g fill="none" fill-rule="evenodd"><path fill="#D8D8D8" d="M0 0h400v300H0z"/>'
 + '<text fill="#9B9B9B" font-family="Times New Roman,Times,serif" font-size="72" font-weight="bold">'
 + '<tspan x="93" y="172">offline</tspan></text></g></svg>',
 
 offlinePage: '/offline.html'
  
};

function cacheName (key, opts) {
  
  return `${opts.version}${key}`;
  
}

function addToCache (cacheKey, request, response) {
  
  if (response.ok) {
    var copy = response.clone();    
    caches.open(cacheKey).then( cache => { cache.put(request, copy); });
  }
  
  return response;
  
}

function fetchFromCache(event) {
  
  return caches.match(event.request).then(response => {
    if (!response) {
      throw Error(`${event.request.url} not found in cache`);
    }
    return response;
  });
  
}

function offlineResponse (resourceType, opts) {
  
  if (resourceType === 'image')
    return new Response(opts.offlineImage, { headers: { 'Content-Type': 'image/svg+xml' } });
  
  if (resourceType === 'content')
    return caches.match(opts.offlinePage);
  
  return undefined;
  
}

self.addEventListener('install', event => {
  
  event.waitUntil(
    caches.open( cacheName('static', config) ).then(cache => cache.addAll(config.precachingItems))
    .then( () => self.skipWaiting() ) 
  );
  
});


self.addEventListener('activate', event => {
  
  function clearCacheIfDifferent(event, opts) {
    
    return caches.keys().then(cacheKeys => {
        var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0);
        var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey));
        return Promise.all(deletePromises);
      });
    
  }

  event.waitUntil(
    clearCacheIfDifferent(event, config).then( () => self.clients.claim() )
  );
    
});


self.addEventListener('fetch', event => {
  
  var request       = event.request;
  var acceptHeader  = request.headers.get('Accept');
  var url           = new URL(request.url);
  var resourceType  = 'static';
  var cacheKey;
  
  
  if( request.method !== 'GET' ) {
      return;
  }
  
  if( url.origin !== self.location.origin ) {
      return;
  }
  
  if( config.blacklistCacheItems.length > 0 && config.blacklistCacheItems.indexOf(url.pathname) >= 0 ) {
      return;
  }

  if (acceptHeader.indexOf('text/html') !== -1) {
    resourceType = 'content';
  }
  else if (acceptHeader.indexOf('image') !== -1) {
    resourceType = 'image';
  }
  
  cacheKey = cacheName(resourceType, config);
  
  // Network First Strategy 
  if (resourceType === 'content') {
    event.respondWith(
      fetch(request)
 .then(response => addToCache(cacheKey, request, response))
 .catch(() => fetchFromCache(event))
 .catch(() => offlineResponse(resourceType, config))
    );
  }
  
  // Cache First Strategy
  else {
    event.respondWith(
      fetchFromCache(event)
 .catch(() => fetch(request))
 .then(response => addToCache(cacheKey, request, response))
 .catch(() => offlineResponse(resourceType, config))
    );
  }
  
}); 

Come aggiornare il service worker

Per aggiornare il service worker, ovvero sostituire quello attualmente installato sul browser con la copia più recente, come prima cosa occorre evitare che il client ne conservi in cache una copia.

Per evitare che ciò accada basta specificare l’opportuna regola di browser caching su Nginx o Apache.

Configurazione per Nginx:

location /service-worker.js {
  add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
  expires off;
  etag off;
  access_log off;
}

Configurazione per Apache:

<Files index.html|service-worker.js>
  FileETag None
  Header unset ETag
  Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"
  Header set Pragma "no-cache"
  Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT"
</Files>

A questo punto basta sostituire sul server il file del service worker con la nuova copia. Sarà il browser, per mezzo di una verifica byte a byte, ad identificare la nuova versione ed a sostituire quella vecchia.

Gli effetti del nuovo service worker saranno visibili a partire dalla seconda visita in poi.

Come disinstallare il service worker

Rimuovere/disinstallare un service worker è un’operazione piuttosto semplice. È possibile eseguirla manualmente dal proprio browser oppure inserendo un semplice script al posto di quello di registrazione del service worker:

<script>
navigator.serviceWorker.getRegistrations().then(function(registrations) {
 for(let registration of registrations) {
   registration.unregister()
 } 
})
</script>

Naturalmente è necessario che la pagina contenente il codice di disinstallazione venga visitata dal browser. È possibile procedere anche alla rimozione manuale su Google Chrome semplicemente aprendo la DevTools e cliccando prima su Application e successivamente su Service Workers.

Accanto al service worker sarà presente un link dal nome Unregister. Non resta che cliccarci sopra per disinstallare e rimuovere il service worker in via definitiva.

Come capire se la navigazione è offline o online

Durante la fase di fetch potrebbe essere utile sapere se la risorsa è stata richiesta durante una navigazione offline piuttosto che online per applicare politiche e strategie differenti.

Per farlo occorre analizzare l’attributo navigator.onLine:

self.addEventListener('fetch', function(event) {
    if( navigator.onLine ) {
      // Qui strategia per navigazione online
    }
    else {
      // Qui strategia per navigazione offline
    }
});

Di seguito un metodo più lento ma più efficace è quello di testare realmente la connessione, evitando così problemi dovuti al lie-fi.

self.addEventListener('fetch', function(event) {

    event.respondWith(
      fetch(event.request).then( function() {
        // Qui strategia per navigazione online
      }).catch( function() {
        // Qui strategia per navigazione offline
      })
    );

}

Conclusione

Se realizzati da un esperto i service worker possono rendere la navigazione del sito web molto ma molto veloce. Grazie alla loro implementazione esterna, non richiedono modifica alcuna al sito web, motivo per cui sono molto apprezzati nel ramo delle web performance.

Se desideri velocizzare il sito web implementando un service worker richiedi una consulenza in performance per ottenere le massime prestazioni ed alta affidabilità.

Salvatore Fresta

Disqus Comments Loading...
Share
Pubblicato da
Salvatore Fresta

Recent Posts

Cloudflare: come identificare la location del server

Cloudflare si antepone tra il server di origine e la richiesta dell'utente per servire file…

18 Marzo 2019

Ottimizzazione MySQL: come scegliere il valore di InnoDB Buffer Pool

L'ottimizzazione del database MySQL è tra i tuning più importanti in tema di performance web.…

5 Marzo 2019

Cache di post e pagine WordPress con Cloudflare

Cloudflare è un ottimo servizio utilizzato sia per migliorare le performance in caso di traffico…

19 Settembre 2018

WordPress, CDN, offloading e compressione immagini

Una delle tante pratiche di ottimizzazione del tempo di caricamento pagina quando si riceve un…

22 Maggio 2018

WordPress e WebP: guida completa

WebP è un formato di immagine sviluppato da Google ed appositamente pensato per ottimizzare il…

17 Maggio 2018

Caching avanzato con i Service Worker

Qualche settimana fa abbiamo visto cosa sono i service worker e come utilizzarli per creare…

12 Maggio 2018