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.
Indice dei contenuti
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.
Un service worker può essere avviato in qualunque contesto purché rispetti i seguenti requisiti:
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%!
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:
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.
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.
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..
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:
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.
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"); });
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.
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.
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); }) ); });
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); }) ); });
È 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)); });
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)); });
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)]) ); });
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; }) ); });
'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)) ); } });
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.
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.
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 }) ); }
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à.