Progressive Web-Apps schrittweise optimieren

Für moderne Browser entwickeln und kontinuierlich verbessern, als wäre es 2003

Im März 2003 verblüfften Nick Finck und Steve Champeon die Webdesign-Welt mit dem Konzept der progressiven Verbesserung, einer Strategie für das Webdesign, bei der zuerst die Hauptinhalte der Webseite geladen werden und dann nach und nach differenziertere und technisch anspruchsvollere Präsentationsebenen und Funktionen hinzugefügt werden. 2003 ging es bei der progressiven Verbesserung darum, die damals modernen CSS-Funktionen, unaufdringliches JavaScript und sogar nur skalierbare Vektorgrafiken zu verwenden. Bei der progressiven Verbesserung im Jahr 2020 und darüber hinaus geht es darum, moderne Browserfunktionen zu nutzen.

Inklusiv-Webdesign für die Zukunft mit progressiver Verbesserung Titelfolie aus der ursprünglichen Präsentation von Finck und Champeon.
Folie: Inklusives Webdesign für die Zukunft mit progressiver Verbesserung. (Quelle)

Modernes JavaScript

Apropos JavaScript: Die Browserunterstützung für die neuesten JavaScript-Kernfunktionen von ES 2015 ist hervorragend. Der neue Standard umfasst unter anderem Promises, Module, Klassen, Vorlagenliterale, Pfeilfunktionen, let und const, Standardparameter, Generatoren, die Destrukturierungszuweisung, Rest und Spread, Map/Set, WeakMap/WeakSet und vieles mehr. Alle werden unterstützt.

Die CanIUse-Unterstützungstabelle für ES6-Funktionen, die die Unterstützung in allen gängigen Browsern zeigt.
Tabelle zur Browserunterstützung von ECMAScript 2015 (ES6). (Quelle)

Asynchrone Funktionen sind eine Funktion von ES 2017 und eine meiner persönlichen Favoriten. Sie können in allen gängigen Browsern verwendet werden. Mit den Keywords async und await können Sie asynchrones, versprechensbasiertes Verhalten übersichtlicher schreiben, ohne Promise-Chains explizit konfigurieren zu müssen.

Die CanIUse-Unterstützungstabelle für asynchrone Funktionen, die die Unterstützung in allen gängigen Browsern zeigt
Tabelle zur Browserunterstützung für asynchrone Funktionen. (Quelle)

Und selbst die jüngsten Spracherweiterungen in ES 2020 wie optionale Verknüpfung und Nullzusammenführung wurden sehr schnell unterstützt. Unten sehen Sie ein Codebeispiel. Was die wichtigsten JavaScript-Funktionen angeht, ist das Gras heute so grün wie nie zuvor.

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah',
  },
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0
Das ikonische grüne Gras-Hintergrundbild von Windows XP.
Bei den wichtigsten JavaScript-Funktionen ist das Gras grüner. (Bildschirmfoto eines Microsoft-Produkts, mit Genehmigung verwendet.)

Die Beispiel-App: Fugu Greetings

In diesem Artikel verwende ich eine einfache PWA namens Fugu Greetings (GitHub). Der Name dieser App ist eine Anspielung auf Project Fugu 🐡, ein Projekt, das dem Web alle Funktionen von Android-/iOS-/Desktop-Anwendungen verleihen soll. Weitere Informationen zum Projekt finden Sie auf der Landingpage.

Fugu Greetings ist eine Zeichen-App, mit der Sie virtuelle Grußkarten erstellen und an Ihre Lieben senden können. Sie veranschaulicht die Grundkonzepte von PWAs. Sie ist zuverlässig und vollständig offlinefähig. Sie können sie also auch ohne Netzwerk nutzen. Sie kann auch auf dem Startbildschirm eines Geräts installiert werden und lässt sich als eigenständige Anwendung nahtlos in das Betriebssystem einbinden.

Fugu Greetings-PWA mit einer Zeichnung, die dem Logo der PWA-Community ähnelt
Die Beispiel-App Fugu Greetings.

Progressive Verbesserung

Jetzt ist es an der Zeit, über progressive Verbesserung zu sprechen. Im MDN Web Docs-Glossar wird das Konzept so definiert:

Progressive Verbesserung ist eine Designphilosophie, die so vielen Nutzern wie möglich grundlegende Inhalte und Funktionen bietet und gleichzeitig die bestmögliche Nutzererfahrung nur für Nutzer der modernsten Browser ermöglicht, die den gesamten erforderlichen Code ausführen können.

Die Funktionserkennung wird in der Regel verwendet, um festzustellen, ob Browser modernere Funktionen verarbeiten können. Polyfills werden hingegen häufig verwendet, um fehlende Funktionen mit JavaScript hinzuzufügen.

[…]

Progressive Verbesserung ist eine nützliche Methode, mit der sich Webentwickler darauf konzentrieren können, die bestmöglichen Websites zu entwickeln und gleichzeitig dafür zu sorgen, dass diese Websites auf mehreren unbekannten User-Agents funktionieren. Graceful Degradation ist ähnlich, aber nicht dasselbe. Es wird oft als gegenteilige Strategie zur progressiven Verbesserung angesehen. In Wirklichkeit sind beide Ansätze gültig und können sich oft gegenseitig ergänzen.

MDN-Mitwirkende

Jede Grußkarte von Grund auf neu zu erstellen, kann sehr mühsam sein. Warum also nicht eine Funktion, mit der Nutzer ein Bild importieren und von dort aus beginnen können? Bei einem traditionellen Ansatz hätten Sie dazu ein <input type=file>-Element verwendet. Zuerst erstellen Sie das Element, legen seine type auf 'file' fest und fügen der accept-Eigenschaft MIME-Typen hinzu. Anschließend klicken Sie programmatisch darauf und warten auf Änderungen. Wenn Sie ein Bild auswählen, wird es direkt in den Canvas importiert.

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

Wenn es eine Importfunktion gibt, sollte es wahrscheinlich auch eine Exportfunktion geben, damit Nutzer ihre Grußkarten lokal speichern können. Die traditionelle Methode zum Speichern von Dateien besteht darin, einen Ankerlink mit dem Attribut download und einer Blob-URL als href zu erstellen. Sie würden auch programmatisch darauf „klicken“, um den Download auszulösen, und hoffentlich nicht vergessen, die URL des Blob-Objekts zu widerrufen, um Speicherlecks zu vermeiden.

const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Aber Moment. Sie haben die Grußkarte nicht „heruntergeladen“, sondern „gespeichert“. Anstatt ein Dialogfeld zum Speichern anzuzeigen, in dem Sie auswählen können, wo die Datei gespeichert werden soll, hat der Browser die Grußkarte ohne Nutzerinteraktion direkt heruntergeladen und in den Downloadordner verschoben. Das ist nicht gut.

Was wäre, wenn es eine bessere Möglichkeit gäbe? Was wäre, wenn Sie eine lokale Datei einfach öffnen, bearbeiten und die Änderungen entweder in einer neuen Datei oder in der ursprünglichen Datei speichern könnten, die Sie ursprünglich geöffnet hatten? Die File System Access API ermöglicht es Ihnen, Dateien und Verzeichnisse zu öffnen und zu erstellen sowie zu ändern und zu speichern.

Wie kann ich also die Funktionen einer API erkennen? Die File System Access API stellt eine neue Methode window.chooseFileSystemEntries() bereit. Daher muss ich unterschiedliche Import- und Exportmodule bedingt laden, je nachdem, ob diese Methode verfügbar ist. Unten habe ich gezeigt, wie das geht.

const loadImportAndExport = () => {
  if ('chooseFileSystemEntries' in window) {
    Promise.all([
      import('./import_image.mjs'),
      import('./export_image.mjs'),
    ]);
  } else {
    Promise.all([
      import('./import_image_legacy.mjs'),
      import('./export_image_legacy.mjs'),
    ]);
  }
};

Bevor ich jedoch auf die Details der File System Access API eingehe, möchte ich kurz das Muster der progressiven Verbesserung hervorheben. In Browsern, die die File System Access API derzeit nicht unterstützen, lade ich die alten Scripts. Unten sehen Sie die Netzwerktabs von Firefox und Safari.

Safari Web Inspector mit den geladenen älteren Dateien
Netzwerktab des Safari Web Inspectors.
Firefox-Entwicklertools, in denen die geladenen Legacy-Dateien angezeigt werden
Netzwerktab der Firefox-Entwicklertools.

In Chrome, einem Browser, der die API unterstützt, werden jedoch nur die neuen Scripts geladen. Das ist dank dynamischer import() möglich, die von allen modernen Browsern unterstützt wird. Wie ich bereits sagte, ist das Gras derzeit ziemlich grün.

Chrome-Entwicklertools, in denen die modernen Dateien geladen werden
Chrome-Entwicklertools: Tab „Netzwerk“

File System Access API

Nachdem ich das geklärt habe, sehen wir uns die tatsächliche Implementierung an, die auf der File System Access API basiert. Zum Importieren eines Bildes rufe ich window.chooseFileSystemEntries() auf und übergebe ihm das Attribut accepts, in dem ich angeben, dass ich Bilddateien möchte. Sowohl Dateiendungen als auch MIME-Typen werden unterstützt. Dies führt zu einem Dateihandle, über den ich die tatsächliche Datei durch Aufrufen von getFile() abrufen kann.

const importImage = async () => {
  try {
    const handle = await window.chooseFileSystemEntries({
      accepts: [
        {
          description: 'Image files',
          mimeTypes: ['image/*'],
          extensions: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
        },
      ],
    });
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Das Exportieren eines Bildes ist fast identisch, aber dieses Mal muss ich der Methode chooseFileSystemEntries() einen Typparameter von 'save-file' übergeben. Daraufhin wird ein Dialogfeld zum Speichern der Datei angezeigt. Wenn die Datei geöffnet ist, ist das nicht erforderlich, da 'open-file' der Standard ist. Ich habe den Parameter accepts ähnlich wie zuvor festgelegt, diesmal jedoch nur auf PNG-Bilder beschränkt. Wieder wird ein Dateihandle zurückgegeben, aber anstatt die Datei abzurufen, erstelle ich diesmal einen beschreibbaren Stream, indem ich createWritable() aufrufe. Als Nächstes schreibe ich den Blob, also das Bild meiner Grußkarte, in die Datei. Schließlich schließe ich den Schreibstream.

Es kann immer etwas schiefgehen: Auf dem Laufwerk ist möglicherweise kein Speicherplatz mehr verfügbar, es kann ein Schreib- oder Lesefehler auftreten oder der Nutzer schließt das Dateidialogfeld einfach nur wieder. Deshalb umschließen ich die Aufrufe immer in einer try...catch-Anweisung.

const exportImage = async (blob) => {
  try {
    const handle = await window.chooseFileSystemEntries({
      type: 'save-file',
      accepts: [
        {
          description: 'Image file',
          extensions: ['png'],
          mimeTypes: ['image/png'],
        },
      ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Mithilfe der File System Access API kann ich eine Datei wie gewohnt öffnen. Die importierte Datei wird direkt auf dem Canvas gezeichnet. Ich kann meine Änderungen vornehmen und sie dann über ein richtiges Speicherdialogfeld speichern, in dem ich den Namen und Speicherort der Datei auswählen kann. Jetzt ist die Datei für die Ewigkeit gesichert.

Fugu Greetings App mit einem Dialogfeld zum Öffnen einer Datei
Das Dialogfeld zum Öffnen der Datei.
Die Fugu Greetings App kann jetzt mit einem importierten Bild verwendet werden.
Das importierte Bild.
Fugu Greetings App mit dem geänderten Bild
Das geänderte Bild in einer neuen Datei speichern.

Web Share API und Web Share Target API

Außer für die Ewigkeit aufzubewahren, möchte ich meine Grußkarte vielleicht auch teilen. Das ist mit der Web Share API und der Web Share Target API möglich. Mobil- und seit Kurzem auch Desktop-Betriebssysteme haben integrierte Freigabemechanismen. Unten sehen Sie beispielsweise das Freigabe-Sheet von Safari auf dem Mac, das über einen Artikel in meinem Blog aufgerufen wurde. Wenn Sie auf die Schaltfläche Artikel teilen klicken, können Sie einen Link zum Artikel mit einem Freund teilen, z. B. über die macOS-Nachrichten-App.

Das Freigabefenster von Safari auf dem Computer unter macOS, das über die Schaltfläche „Teilen“ eines Artikels aufgerufen wird
Web Share API in Safari auf dem Mac.

Der Code dafür ist ziemlich einfach. Ich rufe navigator.share() auf und übergebe optional title, text und url in einem Objekt. Was ist, wenn ich ein Bild anhängen möchte? Level 1 der Web Share API unterstützt dies noch nicht. Die gute Nachricht ist, dass die Web Share Level 2-Funktionen um Dateifreigabefunktionen erweitert wurden.

try {
  await navigator.share({
    title: 'Check out this article:',
    text: `"${document.title}" by @tomayac:`,
    url: document.querySelector('link[rel=canonical]').href,
  });
} catch (err) {
  console.warn(err.name, err.message);
}

Ich zeige Ihnen, wie das mit der Fugu-Grußkartenanwendung funktioniert. Zuerst muss ich ein data-Objekt mit einem files-Array aus einem Blob und dann eine title und eine text vorbereiten. Als Nächstes verwende ich als Best Practice die neue navigator.canShare()-Methode, die genau das tut, was ihr Name vermuten lässt: Sie gibt mir an, ob das data-Objekt, das ich freigeben möchte, technisch vom Browser freigegeben werden kann. Wenn navigator.canShare() mir mitteilt, dass die Daten weitergegeben werden können, bin ich bereit, navigator.share() wie zuvor anzurufen. Da alles schiefgehen kann, verwende ich wieder einen try...catch-Block.

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!(navigator.canShare(data))) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Wie zuvor verwende ich die progressive Verbesserung. Wenn sowohl 'share' als auch 'canShare' im navigator-Objekt vorhanden sind, lade ich share.mjs über dynamische import() nur dann. In Browsern wie Safari für Mobilgeräte, die nur eine der beiden Bedingungen erfüllen, wird die Funktion nicht geladen.

const loadShare = () => {
  if ('share' in navigator && 'canShare' in navigator) {
    import('./share.mjs');
  }
};

Wenn ich in Fugu Greetings in einem unterstützten Browser wie Chrome auf Android auf die Schaltfläche Teilen tippe, wird das integrierte Freigabe-Sheet geöffnet. Ich kann beispielsweise Gmail auswählen und das Widget für den E-Mail-Editor wird mit dem angehängten Bild angezeigt.

Freigabebereich auf Betriebssystemebene mit verschiedenen Apps, über die das Bild geteilt werden kann
App auswählen, für die die Datei freigegeben werden soll.
Das Gmail-Widget zum Verfassen von E-Mails mit angehängtem Bild.
Die Datei wird im Gmail-Editor an eine neue E-Mail angehängt.

Contact Picker API

Als Nächstes möchte ich über Kontakte sprechen, also das Adressbuch oder die Kontaktverwaltungs-App eines Geräts. Wenn Sie eine Grußkarte schreiben, ist es manchmal nicht ganz einfach, den Namen einer Person richtig zu schreiben. Ich habe beispielsweise einen Freund namens Sergej, der seinen Namen lieber in kyrillischen Buchstaben geschrieben haben möchte. Ich verwende eine deutsche QWERTZ-Tastatur und weiß nicht, wie ich den Namen eingeben soll. Dieses Problem kann mit der Contact Picker API gelöst werden. Da ich meinen Freund in der Kontakte App meines Smartphones gespeichert habe, kann ich über die Contacts Picker API auf meine Kontakte im Web zugreifen.

Zuerst muss ich die Liste der Properties angeben, auf die ich zugreifen möchte. In diesem Fall benötige ich nur die Namen, aber für andere Anwendungsfälle könnte ich an Telefonnummern, E-Mail-Adressen, Avatarsymbolen oder Postanschriften interessiert sein. Als Nächstes konfiguriere ich ein options-Objekt und setze multiple auf true, damit ich mehrere Einträge auswählen kann. Schließlich kann ich navigator.contacts.select() aufrufen, wodurch die gewünschten Eigenschaften für die vom Nutzer ausgewählten Kontakte zurückgegeben werden.

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Und Sie haben wahrscheinlich schon das Muster erkannt: Ich lade die Datei nur dann, wenn die API tatsächlich unterstützt wird.

if ('contacts' in navigator) {
  import('./contacts.mjs');
}

Wenn ich in Fugu Greeting auf die Schaltfläche Kontakte tippe und meine beiden besten Freunde, Сергей Михайлович Брин und 劳伦斯·爱德华·"拉里"·佩奇, auswähle, sehen Sie, dass in der Kontaktauswahl nur ihre Namen, aber nicht ihre E-Mail-Adressen oder andere Informationen wie ihre Telefonnummern angezeigt werden. Die Namen werden dann auf meine Grußkarte geschrieben.

Kontaktauswahl mit den Namen von zwei Kontakten im Adressbuch
Zwei Namen mit der Kontaktauswahl aus dem Adressbuch auswählen.
Die Namen der beiden zuvor ausgewählten Kontakte, die auf der Grußkarte gezeichnet wurden.
Die beiden Namen werden dann auf die Grußkarte gezeichnet.

Die Asynchronous Clipboard API

Als Nächstes geht es um das Kopieren und Einfügen. Eine unserer Lieblingsaktionen als Softwareentwickler ist das Kopieren und Einfügen. Als Verfasser von Grußkarten möchte ich das manchmal auch tun. Ich möchte entweder ein Bild in eine Grußkarte einfügen, an der ich gerade arbeite, oder meine Grußkarte kopieren, damit ich sie an anderer Stelle weiter bearbeiten kann. Die Async Clipboard API unterstützt sowohl Text als auch Bilder. Ich erkläre Ihnen, wie ich der Fugu Greetings App die Unterstützung für Kopieren und Einfügen hinzugefügt habe.

Wenn ich etwas in die Zwischenablage des Systems kopieren möchte, muss ich darauf schreiben. Die Methode navigator.clipboard.write() verwendet ein Array von Zwischenablageelementen als Parameter. Jedes Zwischenablageelement ist im Grunde ein Objekt mit einem Blob als Wert und dem Typ des Blobs als Schlüssel.

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Zum Einfügen muss ich die Elemente aus der Zwischenablage durchgehen, die ich durch Aufrufen von navigator.clipboard.read() erhalte. Das liegt daran, dass sich möglicherweise mehrere Zwischenablageelemente in unterschiedlichen Darstellungen in der Zwischenablage befinden. Jedes Zwischenablageelement hat ein types-Feld, das mir die MIME-Typen der verfügbaren Ressourcen anzeigt. Ich rufe die getType()-Methode des Zwischenablage-Elements auf und übergebe den zuvor abgerufenen MIME-Typ.

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Das ist mittlerweile fast selbstverständlich. Ich mache das nur in unterstützten Browsern.

if ('clipboard' in navigator && 'write' in navigator.clipboard) {
  import('./clipboard.mjs');
}

Wie funktioniert das in der Praxis? Ich habe ein Bild in der macOS-Vorschau geöffnet und kopiere es in die Zwischenablage. Wenn ich auf Einfügen klicke, werde ich von der Fugu Greetings App gefragt, ob ich der App erlauben möchte, Text und Bilder in der Zwischenablage zu sehen.

In der App „Fugu Greetings“ wird die Aufforderung zur Berechtigung für die Zwischenablage angezeigt.
Die Aufforderung zur Berechtigung für die Zwischenablage.

Nachdem Sie die Berechtigung akzeptiert haben, wird das Bild in die Anwendung eingefügt. Das funktioniert auch umgekehrt. Ich kopiere eine Grußkarte in die Zwischenablage. Wenn ich dann die Vorschau öffne und auf Datei und dann auf Neu aus Zwischenablage klicke, wird die Grußkarte in ein neues unbenanntes Bild eingefügt.

Die macOS-Vorschau-App mit einem unbenannten, gerade eingefügten Bild.
Ein Bild, das in die macOS-Vorschau-App eingefügt wurde.

Die Badging API

Eine weitere nützliche API ist die Badging API. Als installierbare PWA hat Fugu Greetings natürlich ein App-Symbol, das Nutzer im App-Dock oder auf dem Startbildschirm platzieren können. Eine unterhaltsame und einfache Möglichkeit, die API zu demonstrieren, besteht darin, sie in Fugu Greetings als Zähler für Stiftstriche zu verwenden. Ich habe einen Event-Listener hinzugefügt, der den Zähler für die Stiftstriche erhöht, wenn das Ereignis pointerdown auftritt, und dann das aktualisierte Symbol-Badge festlegt. Wenn der Canvas gelöscht wird, wird der Zähler zurückgesetzt und das Symbol entfernt.

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

Diese Funktion ist eine progressive Verbesserung. Die Ladelogik funktioniert also wie gewohnt.

if ('setAppBadge' in navigator) {
  import('./badge.mjs');
}

In diesem Beispiel habe ich die Zahlen 1 bis 7 mit jeweils einem Strich gezeichnet. Die Anzahl der Badges auf dem Symbol ist jetzt auf sieben gestiegen.

Die Zahlen 1 bis 7, die auf die Grußkarte gezeichnet wurden, jeweils mit nur einem Stiftstrich.
Die Zahlen 1 bis 7 mit sieben Stiftstrichen zeichnen.
Symbol in der Fugu Greetings App mit der Zahl 7
Der Zähler für die Striche in Form des App-Symbols.

Periodic Background Sync API

Möchtest du jeden Tag mit etwas Neuem beginnen? Eine praktische Funktion der Fugu Greetings App ist, dass Sie jeden Morgen mit einem neuen Hintergrundbild für Ihre Grußkarte inspiriert werden können. Dazu verwendet die App die Periodic Background Sync API.

Im ersten Schritt müssen Sie ein register. Es wartet auf ein Synchronisierungs-Tag namens 'image-of-the-day' und hat ein Mindestintervall von einem Tag, damit der Nutzer alle 24 Stunden ein neues Hintergrundbild erhält.

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Im zweiten Schritt müssen Sie im Service Worker auf das Ereignis periodicsync lauschen. Wenn das Ereignis-Tag 'image-of-the-day' ist, also das, das zuvor registriert wurde, wird das Bild des Tages über die Funktion getImageOfTheDay() abgerufen und das Ergebnis an alle Clients weitergegeben, damit sie ihre Canvases und Caches aktualisieren können.

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        const blob = await getImageOfTheDay();
        const clients = await self.clients.matchAll();
        clients.forEach((client) => {
          client.postMessage({
            image: blob,
          });
        });
      })()
    );
  }
});

Auch dies ist eine echte progressive Verbesserung. Der Code wird also nur geladen, wenn die API vom Browser unterstützt wird. Das gilt sowohl für den Clientcode als auch für den Service Worker-Code. In nicht unterstützten Browsern wird keines der beiden geladen. Beachten Sie, dass ich im Service Worker anstelle einer dynamischen import() (die noch nicht in einem Service Worker-Kontext unterstützt wird) die klassische importScripts() verwende.

// In the client:
const registration = await navigator.serviceWorker.ready;
if (registration && 'periodicSync' in registration) {
  import('./periodic_background_sync.mjs');
}
// In the service worker:
if ('periodicSync' in self.registration) {
  importScripts('./image_of_the_day.mjs');
}

In Fugu Greetings wird durch Drücken der Schaltfläche Hintergrund das Grußkartenbild des Tages angezeigt, das jeden Tag über die Periodic Background Sync API aktualisiert wird.

Die Fugu Greetings App mit einem neuen Grußkartenbild des Tages.
Wenn Sie auf die Schaltfläche Hintergrund tippen, wird das Bild des Tages angezeigt.

Notification Triggers API

Manchmal braucht man auch bei viel Inspiration einen kleinen Anstoß, um eine angefangene Grußkarte fertigzustellen. Diese Funktion wird durch die Notification Triggers API aktiviert. Als Nutzer kann ich eine Uhrzeit eingeben, zu der ich daran erinnert werden möchte, meine Grußkarte fertigzustellen. Dann erhalte ich eine Benachrichtigung, dass meine Grußkarte bereit ist.

Nachdem Sie nach der gewünschten Uhrzeit gefragt wurden, plant die Anwendung die Benachrichtigung mit einer showTrigger. Das kann ein TimestampTrigger mit dem zuvor ausgewählten Zieldatum sein. Die Erinnerung wird lokal ausgelöst, eine Netzwerk- oder Serverseite ist nicht erforderlich.

const targetDate = promptTargetDate();
if (targetDate) {
  const registration = await navigator.serviceWorker.ready;
  registration.showNotification('Reminder', {
    tag: 'reminder',
    body: "It's time to finish your greeting card!",
    showTrigger: new TimestampTrigger(targetDate),
  });
}

Wie bei allem, was ich bisher gezeigt habe, handelt es sich hierbei um eine progressive Verbesserung. Der Code wird also nur bedingt geladen.

if ('Notification' in window && 'showTrigger' in Notification.prototype) {
  import('./notification_triggers.mjs');
}

Wenn ich in Fugu Greetings das Kästchen Erinnerung anklicke, werde ich gefragt, wann ich daran erinnert werden möchte, meine Grußkarte fertigzustellen.

Die Fugu Greetings App mit einer Aufforderung, in der der Nutzer gefragt wird, wann er daran erinnert werden möchte, seine Grußkarte fertigzustellen.
Eine lokale Benachrichtigung planen, um daran erinnert zu werden, eine Grußkarte fertigzustellen.

Wenn eine geplante Benachrichtigung in Fugu Greetings ausgelöst wird, wird sie wie jede andere Benachrichtigung angezeigt. Wie bereits erwähnt, ist dafür keine Netzwerkverbindung erforderlich.

macOS-Mitteilungszentrale mit einer ausgelösten Benachrichtigung von Fugu Greetings
Die ausgelöste Benachrichtigung wird in der macOS-Mitteilungszentrale angezeigt.

Wake Lock API

Ich möchte auch die Wake Lock API einbinden. Manchmal muss man einfach nur lange genug auf den Bildschirm starren, bis die Inspiration zuschlägt. Im schlimmsten Fall schaltet sich das Display aus. Mit der Wake Lock API lässt sich das verhindern.

Im ersten Schritt müssen Sie mit der navigator.wakelock.request method() eine Wake-Lock erhalten. Ich übergebe den String 'screen', um eine Sperre für das Aufwecken des Displays zu erhalten. Dann füge ich einen Ereignis-Listener hinzu, um benachrichtigt zu werden, wenn die Sperre aufgehoben wird. Das kann beispielsweise passieren, wenn sich die Sichtbarkeit des Tabs ändert. In diesem Fall kann ich die Wake-Lock wiedererlangen, sobald der Tab wieder sichtbar wird.

let wakeLock = null;
const requestWakeLock = async () => {
  wakeLock = await navigator.wakeLock.request('screen');
  wakeLock.addEventListener('release', () => {
    console.log('Wake Lock was released');
  });
  console.log('Wake Lock is active');
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('fullscreenchange', handleVisibilityChange);

Ja, das ist eine progressive Verbesserung. Ich muss sie also nur laden, wenn der Browser die API unterstützt.

if ('wakeLock' in navigator && 'request' in navigator.wakeLock) {
  import('./wake_lock.mjs');
}

In Fugu Greetings gibt es ein Kästchen für Schlaflosigkeit. Wenn Sie es anklicken, bleibt der Bildschirm eingeschaltet.

Wenn das Kästchen für Schlaflosigkeit angeklickt ist, bleibt das Display eingeschaltet.
Wenn Sie das Kästchen Insomnia aktivieren, bleibt die App aktiv.

Die Idle Detection API

Manchmal ist es einfach nutzlos, auch wenn Sie stundenlang auf den Bildschirm starren, und Sie können sich nicht die geringste Idee dazu einfallen lassen, was Sie mit Ihrer Grußkarte machen sollen. Mit der Idle Detection API kann die App die Inaktivität von Nutzern erkennen. Wenn der Nutzer zu lange inaktiv ist, wird die App auf den ursprünglichen Zustand zurückgesetzt und der Canvas gelöscht. Diese API ist derzeit an die Berechtigung „Benachrichtigungen“ gebunden, da viele Produktionsanwendungsfälle der Inaktivitätserkennung mit Benachrichtigungen zusammenhängen, z. B. um nur eine Benachrichtigung an ein Gerät zu senden, das der Nutzer gerade aktiv nutzt.

Nachdem ich sichergestellt habe, dass die Benachrichtigungsberechtigung erteilt wurde, erstelle ich den Inaktivitätsdetektor. Ich registriere einen Ereignis-Listener, der auf Inaktivitätsänderungen wartet, einschließlich des Nutzers und des Bildschirmstatus. Der Nutzer kann aktiv oder inaktiv sein und der Bildschirm kann entsperrt oder gesperrt sein. Wenn der Nutzer inaktiv ist, wird der Canvas gelöscht. Ich lege für den Inaktivitätsmelder einen Grenzwert von 60 Sekunden fest.

const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', () => {
  const userState = idleDetector.userState;
  const screenState = idleDetector.screenState;
  console.log(`Idle change: ${userState}, ${screenState}.`);
  if (userState === 'idle') {
    clearCanvas();
  }
});

await idleDetector.start({
  threshold: 60000,
  signal,
});

Wie immer lade ich diesen Code nur, wenn der Browser ihn unterstützt.

if ('IdleDetector' in window) {
  import('./idle_detection.mjs');
}

In der Fugu Greetings App wird der Canvas gelöscht, wenn das Kästchen Ephemerid angeklickt ist und der Nutzer zu lange inaktiv ist.

Die App „Fugu Greetings“ mit einem leeren Canvas, nachdem der Nutzer zu lange inaktiv war.
Wenn das Kästchen Ephemeral angeklickt ist und der Nutzer zu lange inaktiv war, wird der Canvas gelöscht.

Abschluss

Puh, was für eine Fahrt. So viele APIs in nur einer Beispiel-App. Und denken Sie daran: Ich lasse Nutzer nie die Downloadkosten für eine Funktion bezahlen, die ihr Browser nicht unterstützt. Mithilfe der progressiven Verbesserung kann ich dafür sorgen, dass nur der relevante Code geladen wird. Da Anfragen mit HTTP/2 kostengünstig sind, sollte dieses Muster für viele Anwendungen gut funktionieren. Für sehr große Apps sollten Sie jedoch einen Bundler in Betracht ziehen.

Im Chrome DevTools-Netzwerkbereich werden nur Anfragen für Dateien mit Code angezeigt, die vom aktuellen Browser unterstützt werden.
Der Tab „Netzwerk“ der Chrome-Entwicklertools, auf dem nur Anfragen für Dateien mit Code angezeigt werden, die vom aktuellen Browser unterstützt werden.

Die App kann in jedem Browser etwas anders aussehen, da nicht alle Plattformen alle Funktionen unterstützen. Die Hauptfunktionen sind jedoch immer verfügbar und werden entsprechend den Funktionen des jeweiligen Browsers kontinuierlich erweitert. Diese Funktionen können sich sogar in einem und demselben Browser ändern, je nachdem, ob die App als installierte App oder in einem Browsertab ausgeführt wird.

Fugu Greetings in Android Chrome, mit vielen verfügbaren Funktionen
Fugu Greetings in Android Chrome.
Fugu Greetings wird in Safari auf dem Computer ausgeführt und zeigt weniger verfügbare Funktionen an.
Fugu Greetings in Safari auf dem Computer.
Fugu Greetings in Chrome auf dem Computer, mit vielen verfügbaren Funktionen
Fugu Greetings in der Desktopversion von Chrome.

Wenn Sie an der Fugu Greetings-App interessiert sind, forken Sie sie auf GitHub.

Fugu Greetings-Repository auf GitHub
Fugu Greetings-App auf GitHub.

Das Chromium-Team arbeitet intensiv daran, die erweiterten Fugu APIs zu verbessern. Durch die Anwendung von progressiver Verbesserung bei der Entwicklung meiner App kann ich dafür sorgen, dass alle Nutzer eine gute, solide Basiserfahrung haben, aber Nutzer, die Browser verwenden, die mehr Webplattform-APIs unterstützen, eine noch bessere Erfahrung haben. Ich bin gespannt, wie Sie die progressive Verbesserung in Ihren Apps einsetzen.

Danksagungen

Ich bin Christian Liebel und Hemanth HM dankbar, die beide zu Fugu Greetings beigetragen haben. Dieser Artikel wurde von Joe Medley und Kayce Basques geprüft. Jake Archibald hat mir geholfen, die Situation mit dynamischen import() in einem Service Worker-Kontext zu klären.