PWA Fundamentals websites with superpowers JS Fest Ukraine 2019 @rowdyrabouw
A presentation at JS Fest Ukraine in November 2019 in Kyiv, Ukraine, 02000 by Rowdy Rabouw
PWA Fundamentals websites with superpowers JS Fest Ukraine 2019 @rowdyrabouw
Доброго ранку! @rowdyrabouw
@rowdyrabouw
Rowdy Raubow @rowdyrabouw
Rowdy Rabouw @rowdyrabouw
@rowdyrabouw
Rowdy Rabouw Born and raised in Gouda, The Netherlands Freelance web and app developer Senior engineer at Nationale-Nederlanden Lead developer Nationale-Nederlanden Pension App Progress Developer Expert for Nativescript @rowdyrabouw rowdy@double-r.nl ❤ superhero movies @rowdyrabouw
@rowdyrabouw
Progressive Web App @rowdyrabouw
Reliable Fast Engaging Always load and never show the downasaur Respond quickly to user interactions Feel like a native app on the device @rowdyrabouw
Key Features • • • • • • • • • Linkable (URL’s) Safe (https) Discoverable (search engines) Responsive (works on every screen resolution) Feel like a native app (fast loading, look-and-feel) Always up-to-date (fresh from the web; no updates from app store) Installable (home screen, cache data) Re-engageable (home screen, push notifications) Connectivity-independent (can work offline) @rowdyrabouw
PWA Progressive Web Apps = ❤ Progressive Modern Browsers Enhancement @rowdyrabouw
Starter Code @rowdyrabouw
@rowdyrabouw
@font-face { font-family: “Exo 2”; font-display: swap; font-style: normal; font-weight: 400; src: url(“..”/fonts/exo2.woff2”) format(“woff2”); } body { padding: 20px; } header img { max-height: 100px; } h1 { color: #df2305; font-family: “Exo 2”, sans-serif; margin: 0; text-align: center; } @rowdyrabouw
Manifest File @rowdyrabouw
Manifest File • • • simple JSON file informs the browser about your web application tells it how it should behave when “installed” @rowdyrabouw
{ } “name”: “PWA Superheroes”, “short_name”: “PWAS”, “lang”: “en-GB”, “start_url”: “/”, “scope”: “.”, “display”: “standalone”, “orientation”: “portrait”, “background_color”: “#3D3D3D”, “theme_color”: “#DF2305”, “icons”: [] @rowdyrabouw
@rowdyrabouw
{ } “name”: “PWA Superheroes”, “short_name”: “PWAS”, “lang”: “en-GB”, “start_url”: “/”, “scope”: “.”, “display”: “standalone”, “orientation”: “portrait”, “background_color”: “#3D3D3D”, “theme_color”: “#DF2305”, “icons”: [ { “src”: “manifest-icon-192.png”, “sizes”: “192x192”, “type”: “image/png” }, { “src”: “manifest-icon-512.png”, “sizes”: “512x512”, “type”: “image/png” } ] @rowdyrabouw
A2HS @rowdyrabouw
A2HS Criteria • • • the web app is not already installed served over HTTPS web app manifest • • • • • • short_name or name start_url display must be one of: fullscreen, standalone, or minimal-ui icons must include a 192px and a 512px sized icons prefer_related_applications is not true registered service worker with a fetch event handler @rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
beforeinstallprompt @rowdyrabouw
@rowdyrabouw
@rowdyrabouw
@rowdyrabouw
main { margin-top: 10px; text-align: center; } #btnInstall { color: #ffffff; background-color: #3d3d3d; border-radius: 10px; display: none; padding: 10px 20px; } .show { display: inline-block !important; } @rowdyrabouw
//” a2hs.js let deferredPrompt; const btnInstall = document.getElementById(“btnInstall”); window.addEventListener(“beforeinstallprompt”, event =>” { console.log(“beforeinstallprompt fired”); //” prevent showing dialog event.preventDefault(); deferredPrompt = event; //” show button to install PWA btnInstall.classList.add(“show”); return false; }); @rowdyrabouw
//” a2hs.js btnInstall.addEventListener(“click”, () =>” { if (deferredPrompt) { //” show native dialog deferredPrompt.prompt(); //” act on user choice deferredPrompt.userChoice.then(choiceResult =>” { if (choiceResult.outcome ===”” “dismissed”) { console.log(“User cancelled installation”); } else { console.log(“User added to home screen”); //” hide button to install PWA btnInstall.classList.remove(“show”); deferredPrompt = null; } }); } }); @rowdyrabouw
Service Workers @rowdyrabouw
Service Workers • • • • • • • JavaScript file runs in the background in a separate thread; so non-blocking scoped to a website can’t modify page; no access to DOM asynchronous; promises or async/await event driven @rowdyrabouw
Man-in-the-middle @rowdyrabouw
Marvel-in-the-middle @rowdyrabouw
//” sw.js self.addEventListener(“install”, event =>” { console.log(“[Service Worker] Installing Service Worker …”“”, event); }); self.addEventListener(“activate”, event =>” { console.log(“[Service Worker] Activating Service Worker …”“”, event); //” ensure that the Service Worker is activated correctly (fail-safe) return self.clients.claim(); }); @rowdyrabouw
//” app.js //” check for modern browser if (“serviceWorker” in navigator) { navigator.serviceWorker .register(“/sw.js”) .then(() =>” { console.log(“Service Worker registered!”); }) .catch(err =>” { console.log(err); }); } @rowdyrabouw
Cache API @rowdyrabouw
//” sw.js self.addEventListener(“install”, event =>” { //” without waiting the SW will continue without caching event.waitUntil( //” opens the existing static
cache or creates it caches.open(“static”).then(cache =>” { console.log(“[Service Worker] Precaching Files”); cache.add(“/”); cache.add(“index.html”); cache.add(“css/app.css”); cache.add(“fonts/exo2.woff2”); cache.add(“js/app.js”); cache.add(“img/pwa.svg”); cache.add(“favicon.png”); cache.add(“manifest.json”); cache.add(“manifest-icon-192.png”); }) ); }); @rowdyrabouw
//” sw.js const STATIC_CACHE = “static-v1”; const STATIC_FILES = [“/”, “index.html”, “css/app.css”, “fonts/exo2.woff2”, “js/app.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; self.addEventListener(“install”, event =>” { event.waitUntil( caches.open(STATIC_CACHE).then(cache =>” { cache.addAll(STATIC_FILES); }) ); }); @rowdyrabouw
Fetch API @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { event.respondWith( //” look at all caches for a match on the key (= request) caches .match(event.request) //” you will always get a response; null if not found .then(response =>” { if (response) { //” if found: return from cache return response; } else { //” fetch it from the server return fetch(event.request); } }) ); }); @rowdyrabouw
швидкість @rowdyrabouw
Cache Cleanup @rowdyrabouw
…”” <main> <button id=”btnInstall” class=”hide”>Add to Home screen</”button> <a href=”about.html”>about</”a> </”main> …”” @rowdyrabouw
//” about.html <!DOCTYPE html> <html lang=”en”> <head> …”” </”head> <body> <header> …”” </”header> <main> <p>This is a demo website a for Progressive Web App talk by Rowdy Rabouw.</”p> <a href=”https://”noti.st/rowdy” target=”_blank”>more info</”a> </”main> </”body> </”html> @rowdyrabouw
a, p { color: #3d3d3d; font-family: “Exo 2”, sans-serif; font-size: 20px; text-decoration-color: #df2305; } @rowdyrabouw
//” sw.js const STATIC_CACHE = “static-v2”; const STATIC_FILES = [“/”, “index.html”, “css/app.css”, “fonts/exo2.woff2”, “js/app.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; @rowdyrabouw
//” sw.js self.addEventListener(“activate”, event =>” { event.waitUntil( //” will return an array of cache names caches.keys().then(keys =>” { return Promise.all( //” go over all available keys keys.map(key =>” { if (key !==”” STATIC_CACHE) { return caches.delete(key); } }) ); }) ); return self.clients.claim(); }); @rowdyrabouw
Dynamic Caching @rowdyrabouw
//” sw.js const DYNAMIC_CACHE = “dynamic-v1”; const STATIC_CACHE = “static-v3”; const STATIC_FILES = [“/”, “index.html”, “css/app.css”, “fonts/exo2.woff2”, “js/app.js”, “js/a2hs.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { event.respondWith( caches.match(event.request).then(response =>” { if (response) { return response; } else { } return fetch(event.request); }) ); }); @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { event.respondWith( caches.match(event.request).then(response =>” { if (response) { return response; } else { return ( fetch(event.request).then(res =>” { return caches.open(DYNAMIC_CACHE).then(cache =>” { cache.put(event.request.url, res.clone()); return res; }); }) ); } }) ); }); @rowdyrabouw
//” sw.js self.addEventListener(“activate”, event =>” { event.waitUntil( caches.keys().then(keys =>” { return Promise.all( keys.map(key =>” { if (key !==”” STATIC_CACHE) { return caches.delete(key); } }) ); }) ); return self.clients.claim(); }); @rowdyrabouw
//” sw.js self.addEventListener(“activate”, event =>” { event.waitUntil( caches.keys().then(keys =>” { return Promise.all( keys.map(key =>” { if (key !==”” STATIC_CACHE &&” key !==”” CACHE_DYNAMIC_NAME) { return caches.delete(key); } }) ); }) ); return self.clients.claim(); }); @rowdyrabouw
Offline Fallback @rowdyrabouw
//” offline.html <!DOCTYPE html> <html lang=”en”> <head> …”” </”head> <body> <header> …”” </”header> <main> <p><b>We’re sorry, this page hasn’t been cached yet :-(</”b></”p> <p>But why don’t you try one of our <a href=”/”>other pages</”a>?</”p> </”main> </”body> </”html> @rowdyrabouw
//” sw.js const DYNAMIC_CACHE = “dynamic-v1”; const STATIC_CACHE = “static-v4”; const STATIC_FILES = [“/”, “index.html”, “offline.html”, “css/app.css”, “fonts/exo2.woff2”, “js/app.js”, “js/a2hs.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { event.respondWith( caches.match(event.request).then(response =>” { if (response) { return response; } else { return ( fetch(event.request).then(res =>” { return caches.open(DYNAMIC_CACHE).then(cache =>” { cache.put(event.request.url, res.clone()); return res; }); }) ); } }) ); }); @rowdyrabouw
//” sw.js …”” return fetch(event.request) .then(res =>” { …”” }) .catch(err =>” { return caches.open(STATIC_CACHE).then(cache =>” { return cache.match(“/offline.html”); }); }); …”” @rowdyrabouw
//” sw.js …”” return fetch(event.request) .then(res =>” { …”” }) .catch(err =>” { return caches.open(STATIC_CACHE).then(cache =>” { if (event.request.headers.get(“accept”).includes(“text/html”)) { return cache.match(“/offline.html”); } }); }); …”” @rowdyrabouw
Application Shell @rowdyrabouw
@rowdyrabouw
…”” <main> <button id=”btnInstall” class=”hide”>Add to Home screen</”button> <a href=”about.html”>about</”a> <div class=”container”> <div class=”placeholder”> <h2 id=”name”> </”h2> </”div> <div class=”placeholder”> <p id=”details”> <br />” </”p> </”div> <div class=”placeholder img-placeholder” id=”image”></”div> </”div> </”main> …”” @rowdyrabouw
h2 { font-family: “Exo 2”, sans-serif; margin-bottom: 0; } .container { width: 240px; margin: auto; } .placeholder { background-color: #f3f3f3; margin-bottom: 10px; animation: colorchange 2.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; animation-iteration-count: infinite; } @rowdyrabouw
.img-placeholder { height: 320px; } img { max-width: 100%; border-top-left-radius: 70px; border-bottom-right-radius: 70px; } @keyframes colorchange { 0% { background: #f3f3f3; } 50% { background: #d1d1d1; } 100% { background: #f3f3f3; } } @rowdyrabouw
//” sw.js const STATIC_CACHE = “static-v5”; @rowdyrabouw
Dynamic Data @rowdyrabouw
//” api.js const url = “https://”www.superheroapi.com/api.php/3251184438288441/213”; fetch(url) .then(res =>” { return res.json(); }) .then(data =>” { console.log(“Retrieved from web”, data); updateUI(“web”, data); }); @rowdyrabouw
//” api.js function updateUI(updateBy, data) { console.log(Update UI by ${updateBy}
); document.getElementById(“name”).innerHTML = data.name; const details = ${data.biography["full-name"]}, ${data.biography["placeof-birth"]}<br/>"${data.appearance.height[1]} / ${data.appearance.weight[1]}
; document.getElementById(“details”).innerHTML = details; document.getElementById(“image”).innerHTML = <img src="$ {data.image.url}" alt="${data.name}" />"
; const all = document.getElementsByClassName(“placeholder”); for (let i = 0; i < all.length; i++”) { all[i].style.backgroundColor = “transparent”; all[i].style.animation = “none”; } } @rowdyrabouw
@rowdyrabouw
//” sw.js const STATIC_CACHE = “static-v6”; const STATIC_FILES = [“/”, “index.html”, “offline.html”, “css/app.css”, “fonts/exo2.woff2”, “js/app.js”, “js/a2hs.js”, “js/api.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; @rowdyrabouw
IndexedDB @rowdyrabouw
IndexedDB An asynchronous transactional key-value database in the browser for data that changes frequently and is typically in JSON format. Transactional means that if one of the actions within an operation fails, none of those actions are applied to keep database integrity. @rowdyrabouw
IndexedDB An asynchronous transactional key-value database in the browser for data that changes frequently and is typically in JSON format. Transactional means that if one of the actions within an operation fails, none of those actions are applied to keep database integrity. @rowdyrabouw
@rowdyrabouw
//” sw.js importScripts(“idb.js”); importScripts(“utils.js”); const DYNAMIC_CACHE = “dynamic-v1”; const STATIC_CACHE = “static-v7”; const STATIC_FILES = [“/”, “index.html”, “offline.html”, “css/app.css”, “idb.js”, “utils.js”, “fonts/exo2.woff2”, “js/app.js”, “js/a2hs.js”, “js/api.js”, “img/pwa.svg”, “favicon.png”, “manifest.json”, “manifest-icon-192.png”]; @rowdyrabouw
//” utils.js const DYNAMIC_DB = “dynamic”; const DYNAMIC_DB_VERSION = 1; const DYNAMIC_DB_STORE = “heroes”; const API_URL = “https://”www.superheroapi.com/api.php/3251184438288441/213”; //” open database and get a callback after it is opened const IDB = idb.open(DYNAMIC_DB, DYNAMIC_DB_VERSION, db =>” { //” create a store if it doesn’t exist already if (!db.objectStoreNames.contains(DYNAMIC_DB_STORE)) { //” name the store and set the primary key db.createObjectStore(DYNAMIC_DB_STORE, { keyPath: “id” }); } }); @rowdyrabouw
//” utils.js function writeData(storeName, data) { console.log(“Write data to cache”, data); return IDB.then(db =>” { const tx = db.transaction(storeName, “readwrite”); const store = tx.objectStore(storeName); store.put(data); return tx.complete; }); } @rowdyrabouw
//” utils.js function clearAllData(storeName) { return IDB.then(db =>” { const tx = db.transaction(storeName, “readwrite”); const store = tx.objectStore(storeName); store.clear(); return tx.complete; }); } @rowdyrabouw
//” utils.js function readAllData(storeName) { return IDB.then(db =>” { const tx = db.transaction(storeName, “readonly”); const store = tx.objectStore(storeName); return store.getAll(); }); } @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { if (event.request.url.indexOf(API_URL) > -1) { event.respondWith( fetch(event.request).then(res =>” { const clonedRes = res.clone(); clearAllData(DYNAMIC_DB_STORE) .then(() =>” { return clonedRes.json(); }) .then(data =>” { writeData(DYNAMIC_DB_STORE, data); }); return res; }) ); } …”” @rowdyrabouw
//” sw.js self.addEventListener(“fetch”, event =>” { if (event.request.url.indexOf(API_URL) > -1) { …”” } else { event.respondWith( caches.match(event.request).then(response =>” { …”” }) ); } }); @rowdyrabouw
//” api.js const url = “https://”www.superheroapi.com/api.php/3251184438288441/213”; let networkDataReceived = false; fetch(API_URL) .then(res =>” { return res.json(); }) .then(data =>” { networkDataReceived = true; console.log(“Retrieved from web”, data); updateUI(“web”, data); }); @rowdyrabouw
//” api.js readAllData(DYNAMIC_DB_STORE).then(data =>” { if (!networkDataReceived) { console.log(“Retrieved from cache”, data[0]); if (typeof data[0] !==”” “undefined”) { updateUI(“cache”, data[0]); } } }); @rowdyrabouw
https://developers.google.com/web/tools/workbox @rowdyrabouw
Background Sync API @rowdyrabouw
Background Sync API • • defer actions until the user has stable connectivity ensure that whatever the user wants to send is sent when they regain connectivity • • behaves similar to the outbox on an email client messages are queued up in the outbox (indexedDB) and as soon as there’s a connection, they’re sent • make sure to notify the user @rowdyrabouw
Periodic Sync API @rowdyrabouw
Periodic Sync API • • sync data on a regular basis; update the PWA • PeriodicSync API will take into account the battery level and while the user is sleeping for instance! network state of the device it’s running on • • powerState: ‘auto’ or ‘avoid-draining’ networkState: ‘avoid-cellular’ @rowdyrabouw
Periodic Sync API E R U T A E F L • • sync data on a regular basis; update the PWA • PeriodicSync API will take into account the battery level and while the user is sleeping for instance! A T N E network state of the device it’s running on • • M I R powerState: ‘auto’ or ‘avoid-draining’ E P networkState: ‘avoid-cellular’ X E @rowdyrabouw
New version available! @rowdyrabouw
#snackbar { visibility: hidden; background-color: #e02205; color: #ffffff; text-align: center; padding: 16px; position: fixed; z-index: 1; left: 0; top: 0; font-family: “Exo 2”, sans-serif; font-size: 20px; border: 3px solid #000000; } #snackbar.show { visibility: visible; } @rowdyrabouw
//” sw.js const STATIC_CACHE = “static-v8”; self.addEventListener(“message”, event =>” { if (event.data.action ===”” “skipWaiting”) { self.skipWaiting(); } }); @rowdyrabouw
//” app.js let newWorker; function showUpdateBar() { let snackbar = document.getElementById(“snackbar”); snackbar.className = “show”; } document.getElementById(“snackbar”).addEventListener(“click”, () =>” { newWorker.postMessage({ action: “skipWaiting” }); }); @rowdyrabouw
//” app.js navigator.serviceWorker .register(“/sw.js”) .then(reg =>” { reg.addEventListener(“updatefound”, () =>” { newWorker = reg.installing; newWorker.addEventListener(“statechange”, () =>” { switch (newWorker.state) { case “installed”: if (navigator.serviceWorker.controller) { showUpdateBar(); } break; } }); }); }) @rowdyrabouw
//” app.js navigator.serviceWorker.addEventListener(“controllerchange”, () =>” { window.location.reload(); }); @rowdyrabouw
https://noti.st/rowdy https://github.com/rowdyrabouw/pwa-superheroes.com @rowdyrabouw
Rowdy Rabouw Born and raised in Gouda, The Netherlands Freelance web and app developer Senior engineer at Nationale-Nederlanden Lead developer Nationale-Nederlanden Pension App Progress Developer Expert for Nativescript @rowdyrabouw rowdy@double-r.nl ❤ superhero movies @rowdyrabouw