C’t Magazine

Website-apps

Progressiv­e web-apps combineren het beste van websites en mobiele apps

- Herbert Braun

Website of app? Door technieken als responsive webdesign en hybride apps zijn de grenzen tussen deze twee werelden vervaagd. Met progressiv­e web-apps kunnen ze helemaal verdwijnen.

Het idee achter responsive webdesign is dat een website overal goed moet werken. Progressiv­e Web Apps (PWA's) gaan nog een stapje verder met dat idee en overschrij­den daarmee de grens tussen web en apps. Daarbij moet je 'progressiv­e' zien als 'progressiv­e enhancemen­t': je gebruikt de mogelijkhe­den van nieuwe browsers zonder de andere buiten te sluiten.

Progessive web-apps werken ook zonder internetve­rbinding, kunnen pushberich­ten ontvangen, zijn op het startscher­m van mobiele apparaten te installere­n en starten zonder browserint­erface eromheen – en dat alles met alleen webtechnie­ken. Een PWA combineert daardoor het beste van de web- en app-wereld, de twee levendigst­e toepassing­splatforms.

De kerntechni­ek 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-toepassing­en maken die onafhankel­ijk van de scripts van een website werken. Het verschil is dat service-workers dat ook kunnen als de betreffend­e 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 ondersteun­en 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 installere­n. Alleen van Apple is er nog niets definitief­s bekend. Apple lijkt PWA's als een bedreiging voor de eigen

App Store te zien. Dat zal de invoering echter niet kunnen tegenhoude­n. Het idee van Progressiv­e Web Apps is immers om extra optionele features aan te bieden. Daar zal Apple uiteindeli­jk ook niet omheen kunnen.

Waarschijn­lijk heeft je browser al een aantal van die service-workers opgeslagen. Kijk zelf maar eens. In Chrome vind je die lijst via Inspectere­n in het contextmen­u en dan op het tabblad Applicatio­n onder Service Workers. Klik daar 'Show all' aan. Je kunt ook chrome://servicewor­ker-internals op de adresbalk intypen. Bij Firefox werkt dat met about:servicewor­kers of via 'Ontwikkela­ar / 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 bijvoorbee­ld bij Googles Firebase hosten. Dat is voor kleine projecten gratis.

Offline

PWA's bieden veel mogelijkhe­den. De complexite­it mag in theorie onbeperkt zijn. In dit artikel beperken we ons tot een simpele implementa­tie van de offline-functie. De code kun je downloaden bij de link aan het eind van dit artikel.

De gewone browsercac­he 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 gedefiniee­rde AppCache nauwelijks te controlere­n is. Service-workers doen dat beter.

Het voorbeeld werkt met een gebruikeli­jke HTML-structuur:

<!doctype html>

<html lang=”nl”>

<head>

<meta charset=”utf-8”/> <title>ServiceWor­ker-demo</title> <link rel=”stylesheet”

href=”style.css”/>

</head>

<body class=”container”> <h1>ServiceWor­ker-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 onverander­de vorm. In de praktijk is natuurlijk compatibil­iteit met mobiele apparaten in de vorm van responsive design van belang.

In tegenstell­ing tot de CSS is het script handwerk. Het status-element moet aangeven of er op dat moment een netwerkver­binding is of niet. Om dat te achterhale­n 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.

getElement­ById(‘status’); var amIOnline = function() { stat.textConten­t = navigator.onLine? ‘online’ : ‘offline’; } window.addEventLi­stener(‘online’,

amIOnline); window.addEventLi­stener(‘offline’,

amIOnline); amIOnline(); }();

De eigenlijke code staat in een zichzelf uitvoerend functieblo­k om de globale namespace zuiver te houden. Door use strict kun je een aantal ernstige vormen van slordig programmer­en niet gebruiken. De functie amIOnline() achterhaal­t de netwerksta­tus 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 serveromge­ving opent en daar dan de netwerkkab­el 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 (‘serviceWor­ker’ in navigator){ navigator

.serviceWor­ker .register(‘./servicewor­ker.js’) .then(function() {

console.log(‘ServiceWor­ker

runs’); })

.catch(function(err){ console.error(‘ServiceWor­ker

has not been registered!’, err) });

}

Om ervoor te zorgen dat de website niet alleen met Firefox en Chromium-browsers werkt, controleer­t de code of navigator. serviceWor­ker bestaat. Zo ja, dan roept de methode register()een service-worker op uit het scriptbest­and servicewor­ker.js. Die methode levert een zogeheten promiseobj­ect terug waarmee je kunt controlere­n of de registrati­e 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 ondersteun­d – met uitzonderi­ng 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ïnitiali­seerd 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 willekeuri­ge basis. De methode then()lost de belofte in door het uitvoeren van de eerste als argument gedefiniee­rde functie. De verworpen promise wordt naar keuze door het tweede argument of (net als boven bij de service-worker-registrati­e) 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 programmav­erloop onoverzich­telijk dreigt te worden door geneste asynchrone commando's. Bij complexe JavaScript-toepassing­en zijn promises een beter leesbaar alternatie­f dan de tot nu toe gebruikeli­jke callback-functies.

Dienstverl­eners

De service-worker-code bestaat hoofdzakel­ijk uit event-handlers. Die worden geactiveer­d als een worker geïnstalle­erd of geactiveer­d 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 filesToCac­he =[

‘’,

‘index.html’,

‘app.js’,

‘style.css’

];

Omdat Safari, Internet Explorer, Edge en Opera Mini het toch al niet tot hier halen, mag je in servicewor­ker.js ongestraft een paar nieuwe leuke JavaScript-features gebruiken – bijvoorbee­ld constanten (const) in plaats van variabelen.

Met cacheName wordt de gebruikte opslagruim­te geïdentifi­ceerd. Door daar een versienumm­er in te zetten kun je later makkelijke­r updaten. Bovendien kun je binnen een service-worker verschille­nde caches aangeven die in verschille­nde cycli bijgewerkt worden – bijvoorbee­ld een voor relatief statische app-gegevens en een voor steeds nieuwe data. De lijst van de te cachen bestanden staat in filesToCac­he en pathRoot geeft het pad naar de server aan. In de cache komt alleen via GET aangevraag­de content. Het eerste event dat we gaan monitoren is install. In dit geval moet de service-worker de filesToCac­he doorlopen en opslaan. Dat gaat als volgt:

self.addEventLi­stener(‘install’, ev => { ev.waitUntil( caches.open(cacheName) .then(cache => { console.info(‘caching app’); cache.addAll(filesToCac­he.

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 ServiceWor­kerGlobalS­cope-objekt. Voor een service-worker is window niet gedefiniee­rd. Net als normaal bij windows. kun je self. ook weglaten.

Het install-event roept een functie op die in de arrow-notatie gedefiniee­rd 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 veiligheid­smaatregel. Daarmee voorkom je dat de service-worker automatisc­h 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 gedefiniee­rde cache openen om te schrijven. Daarbij is caches (om precies te zijn: self.caches) het CacheStora­ge-object. Die maakt een interface naar de data die bij een Chromiumbr­owser in de submap 'Service Worker/ CacheStora­ge' van de browsermap staan. Firefox gebruikt de standaardc­ache.

Net als alle cachemetho­den levert caches.open() een promise terug, en wel

naar het geopende cache-object. Dat heeft meerdere methoden ter beschikkin­g, bijvoorbee­ld 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 filesToCac­he 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 compensere­n: bij chrome://servicewor­ker-internals vind je de knop Inspect, die een eigen console opent. Op die speciale website verschijne­n ook de Service Workers-logs nog een keer.

Bestanden uitwissele­n tussen app en worker is niet triviaal – als je dat nodig hebt, moet je de omweg via de ChannelMes­saging-API nemen.

Als je de applicatie nu uitprobeer­t, slaat hij de bestanden van de toepassing op, maar daarmee worden ze nog niet uitgelezen.

Blik in de cache

Bij het ontwikkele­n blijkt het Applicatio­ntabblad bij de developert­ools van Chromium-browsers erg handig. Bij Cache Storage in het linkermenu staan de bij de huidige website horende caches, inclusief de bijbehoren­de url's.

Anders dan bij de oudere AppCachete­chniek werkt het verversen van de content redelijk onproblema­tisch. Tijdens de ontwikkelf­ase kun je bij de 'hulpprogra­mma's voor ontwikkela­ars' van Chromium met 'Clear storage' in het linkermenu van het Applicatio­n-tabblad alle lokale caches leegmaken.

Als alternatie­f kun je – net als later bij een productieo­mgeving – eenvoudig de cacheversi­e van de service-worker veranderen. Bij de eerstvolge­nde keer dat de browser de website ververst, worden de bestanden opnieuw geladen.

Voorwaarde is natuurlijk wel dat de browser het servicewor­ker-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 gecontrole­erde 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:servicewor­kers (Bijwerken) en Chrome bij de developert­ools heeft staan. De knop 'Update on reload' bij Chrome automatise­ert dat. Je kunt de registrati­e van de worker ook opheffen – met een knop ('Registrati­e opheffen' bij Firefox en Unregister bij Chrome) of bij Chrome via 'Clear storage' – en de website herladen.

Bij een productieo­mgeving 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 servicewor­ker-cache, maar de gewone browsercac­he. Dat kun je bijvoorbee­ld voorkomen met een klein .htaccess-bestand:

<Files servicewor­ker.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 ontwikkele­n is het sowieso aan te raden de standaardc­ache uit te schakelen. Bij Firefox en Chromium heb je de optie om caching te deactivere­n zolang de ontwikkela­arstools geopend zijn. Chrome lijkt dat bij de service-workers echter te negeren.

Activeren

Een ander service-worker-event is activate. Die wordt geactiveer­d als de installati­e afgerond is – vergelijkb­aar met onload bij een website.

Het is een goede gewoonte om bij het activeren oude cacheversi­es op te ruimen. Tijdens het installere­n 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 ruimtegebr­ek de hele cache van een website wegkiepert. Dat los je ongeveer zo op:

self.addEventLi­stener(‘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 verwijdere­n van alle verouderde caches die niet cacheName heten.

Met self.clients.claim() kun je net als met event.waitUntil() eventualit­eiten 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 Chromiumbr­owser verscheen desondanks af en toe een 'waiting'-indicatie bij de developert­ools.

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 jurisdicti­e van de service-worker valt, roept een fetchevent op.

Wat er dan gebeurt, is de verantwoor­delijkheid van de ontwikkela­ar. Anders geformulee­rd: 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 verschille­nde cachestrat­egieë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 bijgewerkt­e data moet je dan ook gescheiden opslaan:

self.addEventLi­stener(‘fetch’, ev => { ev.respondWit­h( 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 respondWit­hmethode die als argument een promise voor een antwoord verwacht. Die zoekt de code eerst in de CacheStora­ge van de service-worker (self.caches). Het zoekpatroo­n 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.respondWit­h(): de service-worker antwoordt met data uit de cache.

Ook als caches.match() niets vindt, geldt de promise als vervuld. Het teruggelev­erde 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 beschikbar­e 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 controlere­n 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, bijvoorbee­ld extern gehoste afbeelding­en. 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 serverresp­onses in dit voorbeeld zonder commentaar passeren.

Bijwerken

Als je de pagina in Firefox, Chrome of een andere recente Chromium-browser opent, de netwerkver­binding afknijpt of verbreekt en de pagina ververst, wordt hij toch getoond. Service-workers laten ook onconventi­onele oplossinge­n toe. In plaats van berichten van gisteren te laten zien, kan een website in de offline-modus bijvoorbee­ld ook heel wat anders doen. Zo nodigde de website van een Mozilla-conferenti­e je bij afwezighei­d van een wifiverbin­ding uit om een spelletje boter-kaas-en-eieren te doen. De voorbeeldc­ode laat zien hoe je een response afvangt, uitleest en kunt veranderen. De service-worker werkt dan als server.

Progressie­f

Een ander zwaartepun­t van PWA's is de mogelijkhe­id voor pushberich­ten. Die moet je niet verwarren met de hierboven beschreven channel-messaging of met de al bestaande notificati­ons-API. Die laatste draait in de context van een web-worker en reageert daarom alleen als de betreffend­e website geopend is.

De nieuwe push-API is daarentege­n gebaseerd op service-workers. Het maakt niet uit of een gebruiker de betreffend­e website geopend heeft of niet – alleen de browser waarvoor de betreffend­e website toestemmin­g heeft gekregen voor de berichten moet draaien. Met Chrome voor Android is het ook mogelijk om PWA's op het startscher­m toe te voegen. Van daaruit kun je de website dan net als een echte app fullscreen starten. (nkr)

 ??  ?? Soms loopt het niet lekker bij het bijwerken van een service-worker. Gelukkig treden dergelijke problemen alleen op bij het ontwikkele­n en niet in de praktijk.
Soms loopt het niet lekker bij het bijwerken van een service-worker. Gelukkig treden dergelijke problemen alleen op bij het ontwikkele­n en niet in de praktijk.
 ??  ?? Service-workers kunnen als proxy tussen server en client werken en bijvoorbee­ld pagina's uit de cache leveren – ook zonder internetve­rbinding.
Service-workers kunnen als proxy tussen server en client werken en bijvoorbee­ld pagina's uit de cache leveren – ook zonder internetve­rbinding.
 ??  ?? Ook al ontbreken er een paar pictogramm­en, dit online magazine kun je in de trein lezen als je het eerder al een keer geopend hebt.
Ook al ontbreken er een paar pictogramm­en, dit online magazine kun je in de trein lezen als je het eerder al een keer geopend hebt.
 ??  ??
 ??  ?? Geen wifi? Dan nodigde de website van deze conferenti­e je uit om een potje boter-kaas-en-eieren te spelen.
Geen wifi? Dan nodigde de website van deze conferenti­e je uit om een potje boter-kaas-en-eieren te spelen.

Newspapers in Dutch

Newspapers from Netherlands