Progressive Web Apps (PWAs) sind ein wichtiges Konzept im Bereich Web Development, das die universelle Bereitstellung per Webbrowser mit der Feature-Vielfalt nativer Software verbindet. Eine besonders erwähnenswerte Eigenschaft progressiver Webanwendungen ist ihre Offline-Processing-Fähigkeit. Diese kommt beispielsweise zum Tragen, wenn ein Benutzer eine E-Mail übermittelt, aber keine Netzwerkverbindung verfügbar ist, um diese zu verarbeiten.
In diesem Tutorial lesen Sie, wie das funktioniert - und lernen in diesem Zuge mehrere wichtige Komponenten einer PWA kennen. Nämlich:
Service Worker,
Synchronisierung,
Sync Events sowie
Den Code für das PWA-Beispiel in diesem Artikel finden Sie auf GitHub.
Service-Worker-Grundlagen
Im Zusammenhang mit Progressive Web Apps wird jede Menge Hirnschmalz investiert, um deren Verhalten möglichst nahe an das von nativen Anwendungen anzugleichen. Dabei spielen Service Worker einer ganz wesentliche Rolle. Im Grunde handelt es sich bei einem Service Worker um eine eingeschränkte Variante eines Worker Thread, der mit dem Main Browser Thread ausschließlich über Event Messages kommuniziert - und keinen DOM-Zugriff hat. Dabei stellt der Service Worker eine Art eigener Umgebung dar (wie wir gleich sehen werden).
Trotz ihrer Limitationen sind Service Worker relativ leistungsfähig, da sie einen eigenen, vom Main Thread unabhängigen Lebenszyklus haben - der eine Vielzahl von Hintergrundoperationen ermöglicht. In unserem Fall geht es dabei in erster Linie um die Sync API, die den Service Worker dazu befähigt, den Zustand der Netzwerkverbindung zu beobachten - und einen Netzwerk-Call solange zu widerholen, bis dieser erfolgreich ist.
Sync-API und -Events
Stellen Sie sich vor, Sie möchten einen "Retry"-Mechanismus konfigurieren, der Folgendes beinhaltet:
Wenn das Netzwerk verfügbar ist, werden Request direkt übermittelt.
Wenn das Netzwerk nicht verfügbar ist, erfolgt der nächste Übermittlungsversuch, sobald es wieder verfügbar ist.
Sobald ein Retry fehlschlägt, kommt beim nächsten Versuch ein exponentieller Back-Off zur Anwendung und die Settings werden verworfen.
Das in der Praxis umzusetzen, würde eine Menge granularer Arbeit nach sich ziehen. Glücklicherweise verfügen Service Worker für exakt diesen Zweck über ein spezielles Sync Event.
Service Worker werden mit navigator.serviceWorker
registriert. Dieses Objekt ist nur in einem sicheren Kontext verfügbar. Die Webseite muss also über HTTPS geladen werden. Unsere Beispielanwendung für diesen Artikel steht in der Tradition der kanonischen TODO-Sample-App. Nun werfen wir einen Blick darauf, wie sich ein neues To-Do mit Hilfe des sync
-Events eines Service Workers abbilden lässt.
Service-Worker-Synchronisierung einrichten
Im Folgenden erörtern wir, wie Sie den gesamten Lebenszyklus einer Progressive Web App managen können, die Prozesse beinhaltet, die synchronisiert werden müssen. Dazu benötigen wir ein Full-Stack-Setup, in dem Requests für ein neues TODO
dem eben beschriebenen Retry-Mechanismus folgen.
Um das Frontend zu bedienen und den API Request zu händeln, nutzen wir Node und Express. Vorausgesetzt, npm
ist installiert, starten Sie eine neue App mit folgendem Call:
$ npm init -y
Das erzeugt das Gerüst für eine neue Applikation. Im nächsten Schritt, gilt es, die express
-Dependency hinzuzufügen:
$ npm install express
Nun können Sie einen einfachen Express-Server erstellen, der statische Dateien bereitstellt. Anschließend erstellen Sie eine neue index.js
-Datei im Root-Verzeichnis und fügen dort Folgendes ein:
const express = require('express');
const path = require('path'); // Required for serving static files
const app = express();
const port = process.env.PORT || 3000; // default to 3000
app.use(express.static(path.join(__dirname, 'public')));
// Serve the main HTML file for all unmatched routes (catch-all)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Wenn Sie diese Anwendung starten, wird sie alles bedienen, was sich in /public
befindet. Um den Ausführungsprozess zu vereinfachen, öffnen Sie die package.json
-Datei, die npm erstellt hat und fügen ein Startskript hinzu:
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
Über die Befehlszeile können Sie die App nun starten:
$ npm run start
Bevor diese Anwendung etwas "tut", müssen Sie eine neue index.html
-Datei in das /public
-Verzeichnis einfügen:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA InfoWorld</title>
</head>
<body>
<h1>My To-Do List</h1>
<input type="text" id="new-task" placeholder="Add a task">
<button onclick="addTask(document.getElementById('new-task').value)">Add</button>
<ul id="task-list"></ul>
<script src="script.js"></script>
</body>
</html>
Wenn Sie nun localhost:3000
aufrufen, erhalten Sie das grundlegende HTML-Layout - inklusive Titel, Input Box und Schaltfläche. Dabei ist zu beachten, dass eine Interaktion mit letztgenanntem Button den Wert eines neuen Task-Inputs übernimmt und diesen an die addTask()
-Funktion übergibt.
Das Hauptskript
addTask()
wird über script.js
bereitgestellt. Sie können den Inhalt dieser Datei einfügen:
if ('serviceWorker' in navigator) { // 1
window.addEventListener('load', () => { // 2
navigator.serviceWorker.register('sw.js') // 3
.then(registration => { // 4
console.log('Service Worker registered', registration); // 5
})
.catch(err => console.error('Service Worker registration failed', err)); // 6
});
}
const taskChannel = new BroadcastChannel('task-channel'); // 7
function addTask(task) { // 8
taskChannel.postMessage({ type: 'add-task', data: task }); // 9
}
Diesen Code haben wir mit nummerierten Kommentaren ausgestattet, die erklären, was die einzelnen Zeilen "tun":
Überprüft, ob
serviceWorker
aufnavigator
vorhanden ist. Das ist nur der Fall, wenn ein sicherer Kontext besteht.Fügt einen Callback zum Load Observer hinzu, falls der
serviceWorker
vorhanden ist, damit dieser reagiert, wenn die Seite geladen wird.Nutzt die
register
-Methode, um die Dateisw.js
als Service Worker zu laden.Nachdem
sw.js
geladen ist, folgt ein Callback mit dem Registrierungsobjekt.Das Registrierungsobjekt kann genutzt werden, um verschiedene Tasks auszuführen - in unserem Fall wird ausschließlich der Erfolg geloggt.
Protokolliert sämtliche Fehler mit Hilfe des
catch() promise
-Callbacks.Erstellt einen
BroadcastChannel
namens "task-channel
". Das ist ein simpler Weg, um Ereignisse an den Service Worker zu übermitteln, der auf dem Codes insw.js
basiert.Die
addTask()
-Funktion wird von der HTML-Datei aufgerufen.Sendet eine Nachricht auf dem
task-channel
, definiert den Type als "add-task
" sowie das Datenfeld als Task an sich.
In diesem Beispiel ignorieren wir, wie das User Interface die Task Creation händeln würde.
Wir könnten auch verschiedene andere Ansätze verwenden - beispielsweise einen optimistischen, bei dem wir den Task in die UI-Liste einfügen und anschließend versuchen, mit dem Backend zu synchronisieren. Alternativ wäre es auch möglich, zuerst einen Backend-Synchronisierungsversuch zu starten und im Erfolgsfall eine Nachricht an die Benutzeroberfläche zu senden, um einen Task hinzuzufügen. Der BroadcastChannel
erleichtert es dabei, Nachrichten in beide Richtungen zu senden: vom Hauptthread zum Service Worker oder umgekehrt.
Wie bereits eingangs erwähnt, ist im Rahmen einer Sicherheitseinschränkung eine HTTPS-Verbindung notwendig, damit serviceWorker
auf navigator
existieren kann. Um diese mit minimalem Aufwand herzustellen, haben wir in diesem Beispiel ngrok verwendet. Dieses praktische Befehlszeilen-Tool öffnet Ihre lokale Umgebung für die Außenwelt - ohne Konfiguration und inklusive HTTPS. Starten Sie etwa die Sample App ($ npm run start
) und lassen den Befehl $ ngrok http 3000
folgen, erzeugt das einen Tunnel und sorgt dafür, dass die HTTP- und HTTPS-Endpunkte angezeigt werden. Damit können Sie die URL-Leiste Ihres Browser füttern. Zum Beispiel:
Forwarding https://8041-35-223-70-178.ngrok-free.app -> http://localhost:3000
Nun können Sie die App über eine HTTPS-Verbindung unter https://8041-35-223-70-178.ngrok-free.app aufrufen.
Mit dem Service Worker interagieren
Die sw.js
-Datei (die wir zuvor über den Browser mit serviceWorker
geladen haben) dient dazu, mit dem Service Worker zu interagieren.
Für das folgende Beispiel nutzen wie IndexedDB
. Der Grund: Es steht dem Browser "frei", Service-Worker-Kontexte nach Belieben zu erstellen oder zu verwerfen, um Ereignisse zu behandeln. Es gibt also keine Garantie dafür, dass derselbe Kontext verwendet wird, um den Broadcast- und den Snyc-Event zu behandeln. Lokale Variablen scheiden insofern als verlässliches Medium aus - LocalStorage
ist für Service Worker nicht verfügbar. Sie könnten zwar CacheStorage
verwenden (das sowohl in Main- als auch in Service Threads verfügbar ist) - das ist allerdings eigentlich dafür gedacht, Antworten auf Requests zwischenzuspeichern.
Das führt uns letztlich zu IndexedDB
, das in allen Service-Worker-Instanzen "lebt". Wir nutzen die integrierte Browser-Datenbank dabei lediglich dafür, einen neuen Task zu pushen, sobald der Add-Task-Broadcast eintritt - und diese wieder zu verwerfen, wenn der Sync Event ansteht. Einen tieferen Einblick in IndexedDB bietet dieses hilfreiche Tutorial.
Im Folgenden werfen wir einen Blick auf den Inhalt von sw.js
. Im Anschluss werfen wir erneut einen kommentierten Blick auf die einzelnen Codezeilen.
const URL = "https://8014-35-223-70-178.ngrok-free.app/"; // 1
const taskChannel = new BroadcastChannel('task-channel'); // 2
taskChannel.onmessage = event => { // 3
persistTask(event.data.data); // 4
registration.sync.register('task-sync'); // 5
};
let db = null; // 6
let request = indexedDB.open("TaskDB", 1); // 7
request.onupgradeneeded = function(event) { // 8
db = event.target.result; // 9
if (!db.objectStoreNames.contains("tasks")) { // 10
let tasksObjectStore = db.createObjectStore("tasks", { autoIncrement: true }); // 11
}
};
request.onsuccess = function(event) { db = event.target.result; }; // 12
request.onerror = function(event) { console.log("Error in db: " + event); }; // 13
persistTask = function(task){ // 14
let transaction = db.transaction("tasks", "readwrite");
let tasksObjectStore = transaction.objectStore("tasks");
let addRequest = tasksObjectStore.add(task);
addRequest.onsuccess = function(event){ console.log("Task added to DB"); };
addRequest.onerror = function(event) { console.log("Error: " + event); };
}
self.addEventListener('sync', async function(event) { // 15
if (event.tag == 'task-sync') {
event.waitUntil(new Promise((res, rej) => { // 16
let transaction = db.transaction("tasks", "readwrite");
let tasksObjectStore = transaction.objectStore("tasks");
let cursorRequest = tasksObjectStore.openCursor();
cursorRequest.onsuccess = function(event) { // 17
let cursor = event.target.result;
if (cursor) {
let task = cursor.value; // 18
fetch(URL + 'todos/add', // a
{ method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ "task" : task })
}).then((serverResponse) => {
console.log("Task saved to backend.");
deleteTasks(); // b
res(); // b
}).catch((err) => {
console.log("ERROR: " + err);
rej(); //c
})
}
}
}))
}
})
async function deleteTasks() { // 19
const transaction = db.transaction("tasks", "readwrite");
const tasksObjectStore = transaction.objectStore("tasks");
tasksObjectStore.clear();
await transaction.complete;
}
Folgendes spielt sich in diesem Code-Block ab:
Weil alle Requests durch denselben, sicheren Tunnel laufen (den wir mit
ngrok
erstellt haben) wird die URL hier gespeichert.Erstellt einen neuen Broadcast-Kanal mit demselben Namen, um auf Nachrichten warten zu können.
Hält nach Message Events in
task-channel
Ausschau. Wenn eine Antwort auf solche Ereignisse erfolgt, laufen dabei die zwei nachfolgenden Punkte ab.Ruft
persistTask()
auf, um den neuen Task inIndexedDB
abzuspeichern.Registriert einen neuen Sync Event. Das ruft die Fähigkeit auf den Plan, intelligent vorzugehen, wenn es darum geht, Requests zu wiederholen. Der Sync-Handler ermöglicht es, ein Promise zu spezifizieren, der einen Retry triggert, sobald das Netzwerk verfügbar ist - und implementiert eine Back-Off-Strategie sowie Give-Up-Bedingungen.
Erstellt eine Referenz für das Datenbankobjekt.
Sorgt für den Erhalt eines "Requests". Sämtliche Inhalte von
IndexedDB
werden asynchron behandelt.Löst das Event
onupgradeneeded
aus, sobald der Zugriff auf eine neue oder aktualisierte Datenbank erfolgt.Innerhalb von
onupgradeneeded
ermöglicht das globaledb
-Objekt Zugriff auf die Datenbank selbst.Ist die Tasks Collection nicht vorhanden, wird sie erstellt.
Wird die Datenbank erfolgreich erstellt, wird sie im
db
-Objekt gespeichert.Schlägt der Versuch fehl, eine Datenbank zu erstellen, wird der Fehler protokolliert.
Ruft die
persistTask()
-Funktion durch den Broadcast Event add-task (4) auf. Hiermit wird lediglich der neue Task Value in die Tasks Collection aufgenommen.Ein Call erfolgt von Broadcast Event (5) an das Sync Event. Dabei wird überprüft, ob das
event.tag
-Feldtask-sync
ist, um sicherzustellen, dass es sich das Task-Sync-Ereignis handelt.event.waitUntil()
ermöglicht, demserviceWorker
mitzuteilen, dass der Prozess erst abgeschlossen ist, wenn sein inhärentesPromise
verwirklicht wurde. Das hat innerhalb eines Sync Events besondere Bedeutung. a. Definiert ein neuesPromise
, das zunächst mit der Datenbank verbunden wird.Innerhalb des
onsuccess
-Callbacks der Datenbank wird ein Cursor verwendet, um den gespeicherten Task zu übernehmen. Dabei dient das WrappingPromise
dazu, mit verschachtelten asynchronen Calls umzugehen.Die Variable mit dem Wert des Broadcast Task. a. Erzeugt einen neuen
fetch
-Request an den EndpunktexpressJS /todos/add
. b. Verläuft dieser Request erfolgreich, wird der Task aus der Datenbank gelöscht und es erfolgt ein Call anres()
, um das äußere Promise aufzulösen. c. Schlägt der Request fehl, erfolgt ein Call anrej()
. Dadurch wird das enthaltene Promise zurückgewiesen und die Sync API darüber "informiert", dass die Anfrage erneut übermittelt werden muss.Die Hilfsmethode
deleteTasks()
löscht alle Aufgaben in der Datenbank.
Das ist eine Menge Arbeit - lohnt sich am Ende aber, wenn Requests bei unzureichender Netzwerkverbindung im Hintergrund mühelos wiederholt werden können. Und zwar In-Borwser und über alle möglichen Device-Kategorien.
PWA-Beispiel testen
Wenn Sie nun die Beispiel-PWA starten und ein To-Do erstellen, wird dieses an das Backend übertragen und dort gespeichert. Interessant wird es nun, wenn Sie über die Dev-Tools (F12) das Netzwerk deaktivieren.
Wenn Sie nun im Offline-Modus ein To-Do hinzufügen, passiert erst einmal nichts - die Sync API überwacht den Netzwerkstatus. Sobald Sie das Netzwerk wieder aktivieren, können Sie beobachten, wie der Request an das Backend übergeben wird. (fm)
Dieser Beitrag basiert auf einem Artikel unserer US-Schwesterpublikation Infoworld.