Website-apps
Progressive web-apps combineren het beste van websites en mobiele apps
Website of app? Door technieken als responsive webdesign en hybride apps zijn de grenzen tussen deze twee werelden vervaagd. Met progressive web-apps kunnen ze helemaal verdwijnen.
Het idee achter responsive webdesign is dat een website overal goed moet werken. Progressive Web Apps (PWA's) gaan nog een stapje verder met dat idee en overschrijden daarmee de grens tussen web en apps. Daarbij moet je 'progressive' zien als 'progressive enhancement': je gebruikt de mogelijkheden van nieuwe browsers zonder de andere buiten te sluiten.
Progessive web-apps werken ook zonder internetverbinding, kunnen pushberichten ontvangen, zijn op het startscherm van mobiele apparaten te installeren en starten zonder browserinterface eromheen – en dat alles met alleen webtechnieken. Een PWA combineert daardoor het beste van de web- en app-wereld, de twee levendigste toepassingsplatforms.
De kerntechniek achter PWA's komt van een aantal nieuwe JavaScript-interfaces rond het thema service-workers. Net als bij de al langer bekende web-workers kun je met service-workers JavaScript-toepassingen maken die onafhankelijk van de scripts van een website werken. Het verschil is dat service-workers dat ook kunnen als de betreffende website niet eens open staat. Ze kunnen als een proxy tussen server en browser fungeren, maar zijn daarbij wel beperkt tot het domein waar ze vandaan komen.
Op dit moment ondersteunen Chromium-browsers als Chrome, Opera en Vivaldi deze feature, ook op mobiele apparaten. Datzelfde geldt voor Firefox. Edge moet bij een van de volgende versies aansluiten en Microsoft wil PWA's ook via de Windows Store laten installeren. Alleen van Apple is er nog niets definitiefs bekend. Apple lijkt PWA's als een bedreiging voor de eigen
App Store te zien. Dat zal de invoering echter niet kunnen tegenhouden. Het idee van Progressive Web Apps is immers om extra optionele features aan te bieden. Daar zal Apple uiteindelijk ook niet omheen kunnen.
Waarschijnlijk heeft je browser al een aantal van die service-workers opgeslagen. Kijk zelf maar eens. In Chrome vind je die lijst via Inspecteren in het contextmenu en dan op het tabblad Application onder Service Workers. Klik daar 'Show all' aan. Je kunt ook chrome://serviceworker-internals op de adresbalk intypen. Bij Firefox werkt dat met about:serviceworkers of via 'Ontwikkelaar / Service Workers'.
Omdat een service-worker redelijk vergaande privileges geniet, is HTTPS een voorwaarde – als de website niet op een localhost staat tenminste. Ook gemengde content is verboden. Als je geen webserver met SSL hebt, kun je bijvoorbeeld bij Googles Firebase hosten. Dat is voor kleine projecten gratis.
Offline
PWA's bieden veel mogelijkheden. De complexiteit mag in theorie onbeperkt zijn. In dit artikel beperken we ons tot een simpele implementatie van de offline-functie. De code kun je downloaden bij de link aan het eind van dit artikel.
De gewone browsercache helpt niets als het apparaat offline is of slechts een deel van de datastroom binnenkomt. Sinds een paar jaar is er wel een techniek om webpagina-content offline op te slaan, namelijk AppCache [1]. Helaas bleek dat de grootste vergissing van HTML5 omdat de statisch gedefinieerde AppCache nauwelijks te controleren is. Service-workers doen dat beter.
Het voorbeeld werkt met een gebruikelijke HTML-structuur:
<!doctype html>
<html lang=”nl”>
<head>
<meta charset=”utf-8”/> <title>ServiceWorker-demo</title> <link rel=”stylesheet”
href=”style.css”/>
</head>
<body class=”container”> <h1>ServiceWorker-demo</h1> <aside>Status: <span id=”status”>
</span></aside>
<script src=”app.js”></script>
</body> </html>
De pagina laadt eerst een stylesheet. Omdat dat hier verder niet van belang is, gebruiken we het compacte CSS-framework Skeleton in bijna onveranderde vorm. In de praktijk is natuurlijk compatibiliteit met mobiele apparaten in de vorm van responsive design van belang.
In tegenstelling tot de CSS is het script handwerk. Het status-element moet aangeven of er op dat moment een netwerkverbinding is of niet. Om dat te achterhalen is geen service-worker nodig: de eigenschap onLine van het object navigator weet dat altijd al. In app.js ziet dat er zo uit:
+function() {
‘use strict’; var isOnline; var stat = document.
getElementById(‘status’); var amIOnline = function() { stat.textContent = navigator.onLine? ‘online’ : ‘offline’; } window.addEventListener(‘online’,
amIOnline); window.addEventListener(‘offline’,
amIOnline); amIOnline(); }();
De eigenlijke code staat in een zichzelf uitvoerend functieblok om de globale namespace zuiver te houden. Door use strict kun je een aantal ernstige vormen van slordig programmeren niet gebruiken. De functie amIOnline() achterhaalt de netwerkstatus en zet die in de HTML. Hij wordt uitgevoerd door een gewone aanroep en door de beide event-listeners online en offline. De werking van de code zie je als je de HTML-pagina inclusief het script in een lokale serveromgeving opent en daar dan de netwerkkabel uittrekt. Je kunt dat effect bij een Chromium-browser makkelijk bereiken door op het tabblad Network de optie Offline aan te vinken. Firefox heeft daarvoor de optie 'Offline werken' in het menu Bestand.
Nu wordt het tijd om de service-worker te integreren:
if (‘serviceWorker’ in navigator){ navigator
.serviceWorker .register(‘./serviceworker.js’) .then(function() {
console.log(‘ServiceWorker
runs’); })
.catch(function(err){ console.error(‘ServiceWorker
has not been registered!’, err) });
}
Om ervoor te zorgen dat de website niet alleen met Firefox en Chromium-browsers werkt, controleert de code of navigator. serviceWorker bestaat. Zo ja, dan roept de methode register()een service-worker op uit het scriptbestand serviceworker.js. Die methode levert een zogeheten promiseobject terug waarmee je kunt controleren of de registratie succesvol was of niet.
Code-beloftes
Service-workers werken overal met promises. Bij die 'beloftes' gaat het om een relatief nieuw concept in JavaScript, maar het wordt al wel door alle gangbare browsers ondersteund – met uitzondering van Internet Explorer en Opera Mini, die bij PWA's sowieso al buiten de boot vallen. Het idee is om het overzicht te houden bij asynchrone commando's. Een elementair voorbeeld:
var p = new Promise (function(resolve, reject){ if (Math.round(Math.random())) resolve(‘yes’); else reject(‘no’);
}); p.then(function(val){
console.log(val); }, function(err){
console.warn(err);
});
De Promise wordt geïnitialiseerd met een functie die de twee functies resolve() en reject() als argumenten meegeeft. De functie resolve() accepteert de promise, reject() verwerpt hem. In het voorbeeld gebeurt dat op willekeurige basis. De methode then()lost de belofte in door het uitvoeren van de eerste als argument gedefinieerde functie. De verworpen promise wordt naar keuze door het tweede argument of (net als boven bij de service-worker-registratie) door een apart p.catch()-commando afgevangen – dat is een kwestie van smaak.
Een dergelijk primitief voorbeeld is zonder promises natuurlijk makkelijk op te lossen, maar dat ziet er anders uit als het programmaverloop onoverzichtelijk dreigt te worden door geneste asynchrone commando's. Bij complexe JavaScript-toepassingen zijn promises een beter leesbaar alternatief dan de tot nu toe gebruikelijke callback-functies.
Dienstverleners
De service-worker-code bestaat hoofdzakelijk uit event-handlers. Die worden geactiveerd als een worker geïnstalleerd of geactiveerd wordt of bij het laden van een url. Daar zijn nog een paar variabelen voor nodig:
‘use strict’; const cacheName = ‘app-v1a’; const pathRoot = ‘/pwa-demo/’ const filesToCache =[
‘’,
‘index.html’,
‘app.js’,
‘style.css’
];
Omdat Safari, Internet Explorer, Edge en Opera Mini het toch al niet tot hier halen, mag je in serviceworker.js ongestraft een paar nieuwe leuke JavaScript-features gebruiken – bijvoorbeeld constanten (const) in plaats van variabelen.
Met cacheName wordt de gebruikte opslagruimte geïdentificeerd. Door daar een versienummer in te zetten kun je later makkelijker updaten. Bovendien kun je binnen een service-worker verschillende caches aangeven die in verschillende cycli bijgewerkt worden – bijvoorbeeld een voor relatief statische app-gegevens en een voor steeds nieuwe data. De lijst van de te cachen bestanden staat in filesToCache en pathRoot geeft het pad naar de server aan. In de cache komt alleen via GET aangevraagde content. Het eerste event dat we gaan monitoren is install. In dit geval moet de service-worker de filesToCache doorlopen en opslaan. Dat gaat als volgt:
self.addEventListener(‘install’, ev => { ev.waitUntil( caches.open(cacheName) .then(cache => { console.info(‘caching app’); cache.addAll(filesToCache.
map(el => { return pathRoot + el;
}));
})
.catch(err => {
console.error(‘Error!’,err); })
); });
Eigenlijk is self sinds de oertijd een synoniem voor window. In worker-code heeft het echter betrekking op de worker zelf, oftewel een ServiceWorkerGlobalScope-objekt. Voor een service-worker is window niet gedefinieerd. Net als normaal bij windows. kun je self. ook weglaten.
Het install-event roept een functie op die in de arrow-notatie gedefinieerd is en die het event-object als argument meekrijgt. Daarbij komt ev => {…} overeen met function(ev) {…}.
Die waitUntil()-methode van het meegegeven event is een veiligheidsmaatregel. Daarmee voorkom je dat de service-worker automatisch stopt voordat een asynchrone taak uitgevoerd is. In onze test met Firefox en Chrome werkte het ook zonder, maar er is geen garantie dat dat altijd zo blijft. De functie waitUntil() geeft een promise terug, maar die wordt hier niet gebruikt.
De functie caches.open(cacheName) spreekt redelijk voor zich: de worker moet de eerder met een constante gedefinieerde cache openen om te schrijven. Daarbij is caches (om precies te zijn: self.caches) het CacheStorage-object. Die maakt een interface naar de data die bij een Chromiumbrowser in de submap 'Service Worker/ CacheStorage' van de browsermap staan. Firefox gebruikt de standaardcache.
Net als alle cachemethoden levert caches.open() een promise terug, en wel
naar het geopende cache-object. Dat heeft meerdere methoden ter beschikking, bijvoorbeeld add(), put(), delete() en keys() – en ook de comfort-methode addAll(). Die krijgt een array van url's mee, roept die een voor een op en onthoudt de requests en responses. In het voorbeeld bevat filesToCache alleen url-fragmenten, daarom voegt een map()-commando ze samen met pathRoot.
Service-workers kunnen in de console van de door hen beheerde website schrijven, maar verder werken ze strikt gescheiden. Omgekeerd is van de console uit geen toegang tot het binnenwerk van een worker mogelijk. Dat is bij Chromium-browsers wel te compenseren: bij chrome://serviceworker-internals vind je de knop Inspect, die een eigen console opent. Op die speciale website verschijnen ook de Service Workers-logs nog een keer.
Bestanden uitwisselen tussen app en worker is niet triviaal – als je dat nodig hebt, moet je de omweg via de ChannelMessaging-API nemen.
Als je de applicatie nu uitprobeert, slaat hij de bestanden van de toepassing op, maar daarmee worden ze nog niet uitgelezen.
Blik in de cache
Bij het ontwikkelen blijkt het Applicationtabblad bij de developertools van Chromium-browsers erg handig. Bij Cache Storage in het linkermenu staan de bij de huidige website horende caches, inclusief de bijbehorende url's.
Anders dan bij de oudere AppCachetechniek werkt het verversen van de content redelijk onproblematisch. Tijdens de ontwikkelfase kun je bij de 'hulpprogramma's voor ontwikkelaars' van Chromium met 'Clear storage' in het linkermenu van het Application-tabblad alle lokale caches leegmaken.
Als alternatief kun je – net als later bij een productieomgeving – eenvoudig de cacheversie van de service-worker veranderen. Bij de eerstvolgende keer dat de browser de website ververst, worden de bestanden opnieuw geladen.
Voorwaarde is natuurlijk wel dat de browser het serviceworker-script van tevoren bijwerkt. Dat werkt in principe net als altijd: aanpassen, opslaan, herladen. Daarbij mag de worker alleen niet meer draaien. Dat houdt in dat alle door hem gecontroleerde tabbladen gesloten zijn. Bij de test met Chrome duurde het daarna nog ongeveer een minuut tot de worker stopte.
Dat kun je versnellen met de Updateknop die Firefox bij about:serviceworkers (Bijwerken) en Chrome bij de developertools heeft staan. De knop 'Update on reload' bij Chrome automatiseert dat. Je kunt de registratie van de worker ook opheffen – met een knop ('Registratie opheffen' bij Firefox en Unregister bij Chrome) of bij Chrome via 'Clear storage' – en de website herladen.
Bij een productieomgeving moet het verversen van de cache geen probleem zijn omdat de workers bij het laden in principe niet werken. Wat echter roet in het eten kan gooien, is de cache – niet de serviceworker-cache, maar de gewone browsercache. Dat kun je bijvoorbeeld voorkomen met een klein .htaccess-bestand:
<Files serviceworker.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
“Sat, 24 Dec 2016 12:00:00 GMT” </Files>
Bij het ontwikkelen is het sowieso aan te raden de standaardcache uit te schakelen. Bij Firefox en Chromium heb je de optie om caching te deactiveren zolang de ontwikkelaarstools geopend zijn. Chrome lijkt dat bij de service-workers echter te negeren.
Activeren
Een ander service-worker-event is activate. Die wordt geactiveerd als de installatie afgerond is – vergelijkbaar met onload bij een website.
Het is een goede gewoonte om bij het activeren oude cacheversies op te ruimen. Tijdens het installeren kun je dat namelijk beter niet doen, omdat er dan misschien nog een oude versie van de applicatie draait. Opruimen is niet alleen een goede gewoonte, maar verkleint ook het risico dat een browser uit ruimtegebrek de hele cache van een website wegkiepert. Dat los je ongeveer zo op:
self.addEventListener(‘activate’, ev => { ev.waitUntil( caches
.keys()
.then(keyList => { keyList.forEach(key => { if (key !== cacheName)
caches.delete(key);
});
})
); return self.clients.claim(); });
De structuur van deze event-handler lijkt op de vorige. De functie caches.keys() levert een array met cachenamen terug. Die gebruikt caches.delete() bij het verwijderen van alle verouderde caches die niet cacheName heten.
Met self.clients.claim() kun je net als met event.waitUntil() eventualiteiten voorkomen. Van tijd tot tijd activeert een browser een service-worker niet als er al een draait. Dat moet het claim-commando voorkomen. Bij tests met een Chromiumbrowser verscheen desondanks af en toe een 'waiting'-indicatie bij de developertools.
Uit het archief
de offline-cache is aangemaakt en goed voorbereid – nu kan hij eindelijk gebruikt gaan worden. Het principe is makkelijk: ieder bestand dat binnen de jurisdictie van de service-worker valt, roept een fetchevent op.
Wat er dan gebeurt, is de verantwoordelijkheid van de ontwikkelaar. Anders geformuleerd: daar kun je een heleboel verkeerd bij doen. Moet de client de bestanden elke keer opvragen, ook als ze nog in de cache staan? Moeten het netwerk en de cache parallel opgeroepen worden (de cache hoeft niet altijd sneller te zijn)? Gebruikt een app de cache alleen als hij geen internet heeft? Jake Archibald beschrijft in zijn Offline Cookbook acht verschillende cachestrategieën (zie de link onderaan dit artikel).
Intuïtief lijkt het zinvol om in principe de cache te gebruiken en internet als fallback te beschouwen. Zo beperk je het aantal webserver-requests. Om content te updaten, moet je in de toekomst een andere cacheName gebruiken. In dat geval leegt de browser de hele cache en wordt alles opnieuw geladen. Vaker bijgewerkte data moet je dan ook gescheiden opslaan:
self.addEventListener(‘fetch’, ev => { ev.respondWith( caches
.match(ev.request) .then(response => { if (response)
return response; return fetch(ev.request) .then(response => {
return response;
})
.catch(err => {
console.error(err); });
})
);
}); Het fetch-event heeft een respondWithmethode die als argument een promise voor een antwoord verwacht. Die zoekt de code eerst in de CacheStorage van de service-worker (self.caches). Het zoekpatroon is het bij het fetch-event horende request-object ev.request. Die bevat request-gegevens als method, referrer en bovenal url.
Als caches.match() een antwoord van de cache gekregen heeft, geeft de code dat response-object terug aan ev.respondWith(): de service-worker antwoordt met data uit de cache.
Ook als caches.match() niets vindt, geldt de promise als vervuld. Het teruggeleverde response-object heeft in dat geval alleen de waarde undefined. De service-worker moet dan toch het netwerk op. Daarvoor gebruikt hij het kernstuk van de fetch-API: de globaal beschikbare fetch()methode. Die accepteert als argument een url-string of een request-object en geeft een promise terug.
Als de url-request lukt, geeft de worker het resultaat aan de client door. De functie fetch() mislukt alleen als er geen verbinding met het netwerk is. Om zuiver te werken, moet je de response controleren voordat je die teruggeeft:
if (!response.ok){ if (response.type === ‘opaque’) console.warn(
‘No access to data’, ev.request.url); else console.warn(‘URL error’, response.status, response.url);
}
Daarbij heeft response.ok de waarde true als de statuscode (response.status) in de buurt van de 200 ligt. Vaak zijn er requests naar resources die buiten het service-worker-domein vallen, bijvoorbeeld extern gehoste afbeeldingen. De worker laat die wel braaf door, maar heeft er geen toegang toe. Dat is te herkennen aan response.type opaque. Ook bij echte fouten zoals 404 en 500 laat de service-worker de serverresponses in dit voorbeeld zonder commentaar passeren.
Bijwerken
Als je de pagina in Firefox, Chrome of een andere recente Chromium-browser opent, de netwerkverbinding afknijpt of verbreekt en de pagina ververst, wordt hij toch getoond. Service-workers laten ook onconventionele oplossingen toe. In plaats van berichten van gisteren te laten zien, kan een website in de offline-modus bijvoorbeeld ook heel wat anders doen. Zo nodigde de website van een Mozilla-conferentie je bij afwezigheid van een wifiverbinding uit om een spelletje boter-kaas-en-eieren te doen. De voorbeeldcode laat zien hoe je een response afvangt, uitleest en kunt veranderen. De service-worker werkt dan als server.
Progressief
Een ander zwaartepunt van PWA's is de mogelijkheid voor pushberichten. Die moet je niet verwarren met de hierboven beschreven channel-messaging of met de al bestaande notifications-API. Die laatste draait in de context van een web-worker en reageert daarom alleen als de betreffende website geopend is.
De nieuwe push-API is daarentegen gebaseerd op service-workers. Het maakt niet uit of een gebruiker de betreffende website geopend heeft of niet – alleen de browser waarvoor de betreffende website toestemming heeft gekregen voor de berichten moet draaien. Met Chrome voor Android is het ook mogelijk om PWA's op het startscherm toe te voegen. Van daaruit kun je de website dan net als een echte app fullscreen starten. (nkr)