Web

PWA-Tutorial: Background Sync per Service Worker

15.07.2024
Von 
Matthew Tyson ist Java-Entwickler und schreibt unter anderem für unsere US-Schwesterpublikation Infoworld.com.
In diesem Tutorial erfahren Sie, wie Offline Processing in Progressive Web Apps funktioniert und welche Rolle Service Worker dabei spielen.
Abbrechende Netzwerkverbindungen stellen für Progressive Web Apps kein Hindernis dar - Service Workers sei Dank.
Abbrechende Netzwerkverbindungen stellen für Progressive Web Apps kein Hindernis dar - Service Workers sei Dank.
Foto: Pasuwan | shutterstock.com

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

  • IndexedDB.

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":

  1. Überprüft, ob serviceWorker auf navigator vorhanden ist. Das ist nur der Fall, wenn ein sicherer Kontext besteht.

  2. Fügt einen Callback zum Load Observer hinzu, falls der serviceWorker vorhanden ist, damit dieser reagiert, wenn die Seite geladen wird.

  3. Nutzt die register-Methode, um die Datei sw.js als Service Worker zu laden.

  4. Nachdem sw.js geladen ist, folgt ein Callback mit dem Registrierungsobjekt.

  5. Das Registrierungsobjekt kann genutzt werden, um verschiedene Tasks auszuführen - in unserem Fall wird ausschließlich der Erfolg geloggt.

  6. Protokolliert sämtliche Fehler mit Hilfe des catch() promise-Callbacks.

  7. Erstellt einen BroadcastChannel namens "task-channel". Das ist ein simpler Weg, um Ereignisse an den Service Worker zu übermitteln, der auf dem Codes in sw.js basiert.

  8. Die addTask()-Funktion wird von der HTML-Datei aufgerufen.

  9. 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:

  1. Weil alle Requests durch denselben, sicheren Tunnel laufen (den wir mit ngrok erstellt haben) wird die URL hier gespeichert.

  2. Erstellt einen neuen Broadcast-Kanal mit demselben Namen, um auf Nachrichten warten zu können.

  3. Hält nach Message Events in task-channel Ausschau. Wenn eine Antwort auf solche Ereignisse erfolgt, laufen dabei die zwei nachfolgenden Punkte ab.

  4. Ruft persistTask() auf, um den neuen Task in IndexedDB abzuspeichern.

  5. 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.

  6. Erstellt eine Referenz für das Datenbankobjekt.

  7. Sorgt für den Erhalt eines "Requests". Sämtliche Inhalte von IndexedDB werden asynchron behandelt.

  8. Löst das Event onupgradeneeded aus, sobald der Zugriff auf eine neue oder aktualisierte Datenbank erfolgt.

  9. Innerhalb von onupgradeneeded ermöglicht das globale db-Objekt Zugriff auf die Datenbank selbst.

  10. Ist die Tasks Collection nicht vorhanden, wird sie erstellt.

  11. Wird die Datenbank erfolgreich erstellt, wird sie im db-Objekt gespeichert.

  12. Schlägt der Versuch fehl, eine Datenbank zu erstellen, wird der Fehler protokolliert.

  13. Ruft die persistTask()-Funktion durch den Broadcast Event add-task (4) auf. Hiermit wird lediglich der neue Task Value in die Tasks Collection aufgenommen.

  14. Ein Call erfolgt von Broadcast Event (5) an das Sync Event. Dabei wird überprüft, ob das event.tag-Feld task-sync ist, um sicherzustellen, dass es sich das Task-Sync-Ereignis handelt.

  15. event.waitUntil() ermöglicht, dem serviceWorker mitzuteilen, dass der Prozess erst abgeschlossen ist, wenn sein inhärentes Promise verwirklicht wurde. Das hat innerhalb eines Sync Events besondere Bedeutung. a. Definiert ein neues Promise, das zunächst mit der Datenbank verbunden wird.

  16. Innerhalb des onsuccess-Callbacks der Datenbank wird ein Cursor verwendet, um den gespeicherten Task zu übernehmen. Dabei dient das Wrapping Promise dazu, mit verschachtelten asynchronen Calls umzugehen.

  17. Die Variable mit dem Wert des Broadcast Task. a. Erzeugt einen neuen fetch-Request an den Endpunkt expressJS /todos/add. b. Verläuft dieser Request erfolgreich, wird der Task aus der Datenbank gelöscht und es erfolgt ein Call an res(), um das äußere Promise aufzulösen. c. Schlägt der Request fehl, erfolgt ein Call an rej(). Dadurch wird das enthaltene Promise zurückgewiesen und die Sync API darüber "informiert", dass die Anfrage erneut übermittelt werden muss.

  18. 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.

Dieser Screenshot zeigt, wo die Einstellung zu finden ist, um die Offline-Funktionalität des PWA-Beispiels zu testen.
Dieser Screenshot zeigt, wo die Einstellung zu finden ist, um die Offline-Funktionalität des PWA-Beispiels zu testen.
Foto: Matthew Tyson | IDG

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.