Progressive Web-Apps schrittweise optimieren

Entwicklung für moderne Browser und immer mehr Verbesserungen wie im Jahr 2003

Im März 2003 beeindruckten Nick Finck und Steve Champeon die Welt des Webdesigns mit dem Konzept der Progressive Enhancement, einer Strategie für Webdesign, bei der das Laden der zentralen Webseiteninhalte zuerst in den Vordergrund gestellt wird und die den Inhalten dann schrittweise nuancierte und technisch strengere Ebenen in Bezug auf Präsentation und Funktionen hinzufügt. Im Jahr 2003 ging es bei Progressive Enhancement um die damalige Verwendung moderner CSS-Funktionen, unaufdringliches JavaScript und sogar nur um skalierbare Vektorgrafiken. Bei der progressiven Verbesserung ab 2020 geht es um den Einsatz moderner Browserfunktionen.

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

Modernes JavaScript

Die Browserunterstützung für die neuesten Core ES 2015-JavaScript-Funktionen ist hervorragend. Der neue Standard umfasst Promis, Module, Klassen, Vorlagenliterale, Pfeilfunktionen, let und const, Standardparameter, Generatoren, die desstrukturierende Zuweisung, Ruhe und Streuung, Map/Set, WeakMap/WeakSet und viele mehr. Alle werden unterstützt.

CanIUse-Supporttabelle für ES6-Funktionen mit Unterstützung für alle gängigen Browser
Die Tabelle zur Browserunterstützung von ECMAScript 2015 (ES6) (Quelle)

Async-Funktionen, eine ES 2017-Funktion und einer meiner persönlichen Favoriten, können in allen gängigen Browsern verwendet werden. Die Schlüsselwörter async und await ermöglichen ein asynchrones, Versprechen-basiertes Verhalten, das übersichtlicher geschrieben werden kann, sodass keine explizite Konfiguration von Promise-Ketten erforderlich ist.

Die CanIUse-Supporttabelle für asynchrone Funktionen, die von allen gängigen Browsern unterstützt werden.
Tabelle zur Unterstützung des Browsers für asynchrone Funktionen. (Quelle)

Auch die neuesten Sprachen, die in ES 2020 hinzugefügt wurden, wie optionale Verkettung und Nullish Coalescing, werden sehr schnell unterstützt. Unten sehen Sie ein Codebeispiel. Was die JavaScript-Kernfunktionen betrifft, könnte das Gras kaum noch umweltfreundlicher sein.

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah',
  },
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0
Das symbolträchtige Hintergrundbild von Windows XP mit grünem Gras.
Die wichtigsten JavaScript-Funktionen sind grün. (Screenshot eines Microsoft-Produkts, verwendet mit Berechtigung.)

Die Beispiel-App: Fugu Begrüßung

In diesem Artikel arbeite ich mit einer einfachen PWA namens Fugu Begrüßungs (GitHub). Der Name dieser App verdeutlicht das Projekt Fugu 🐡 – ein Ziel, dem Web alle Funktionalitäten von Android-, iOS- und Desktop-Anwendungen zu verleihen. Weitere Informationen zum Projekt finden Sie auf der Landingpage.

Fugu Begrüßungs ist eine Zeichen-App, mit der Sie virtuelle Grußkarten erstellen und an Ihre Lieben senden können. Darin werden die Kernkonzepte von PWAs veranschaulicht. Es ist zuverlässig und vollständig offline aktiviert. Sie können es also auch ohne Netzwerk verwenden. Sie ist außerdem auf dem Startbildschirm eines Geräts installierbar und als eigenständige Anwendung nahtlos in das Betriebssystem integriert.

Fugu Graust die PWA mit einer Zeichnung, die dem Logo der PWA-Community ähnelt.
Die Beispiel-App Fugu Begrüßungs.

Progressive Enhancement

Nachdem das abgeschlossen ist, ist es an der Zeit, über Progressive Verbesserung zu sprechen. Das MDN Web Docs-Glossar definiert das Konzept so:

Progressive Enhancement ist eine Designphilosophie, die so vielen Nutzern wie möglich grundlegende Inhalte und Funktionen bietet und gleichzeitig das bestmögliche Erlebnis nur für Nutzer der modernsten Browser bietet, in denen der gesamte erforderliche Code ausgeführt werden kann.

Die Funktionserkennung wird im Allgemeinen verwendet, um zu bestimmen, ob Browser modernere Funktionen verarbeiten können. Polyfills werden dagegen häufig verwendet, um fehlende Elemente mit JavaScript hinzuzufügen.

[…]

Progressive Enhancement ist eine nützliche Technik, mit der sich Webentwickler darauf konzentrieren können, die bestmöglichen Websites zu entwickeln, während diese Websites mit mehreren unbekannten User-Agents funktionieren. Graceful Degradation ist verwandt, ist aber nicht dasselbe. Es wird oft so betrachtet, als würden sie in die entgegengesetzte Richtung zu Progressive Enhancement wechseln. In Wirklichkeit sind beide Ansätze gültig und ergänzen sich häufig.

MDN-Mitwirkende

Jede Grußkarte von Grund auf neu zu beginnen, kann sehr umständlich sein. Warum also nicht eine Funktion anbieten, mit der Nutzende ein Image importieren und von dort aus beginnen können? Bei einem herkömmlichen Ansatz hätten Sie dazu ein <input type=file>-Element verwendet. Sie erstellen zuerst das Element, setzen type auf 'file' und fügen dem Attribut accept MIME-Typen hinzu. Dann „klicken“ Sie es programmatisch und warten Sie auf Änderungen. Wenn Sie ein Bild auswählen, wird es direkt auf 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 ein import gibt, sollte es wahrscheinlich auch ein import geben, damit Nutzer ihre Grußkarten lokal speichern können. Die herkömmliche Methode zum Speichern von Dateien besteht darin, einen Ankerlink mit einem download-Attribut 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 Blob-Objekt-URL zu widerrufen, um Speicherlecks zu verhindern.

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 warten Sie einen Moment. Sie haben eine Grußkarte gar nicht „heruntergeladen“, sondern „gespeichert“. Anstatt Ihnen 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 direkt im Ordner „Downloads“ abgelegt. Das ist nicht gerade toll.

Was wäre, wenn es eine bessere Lösung gäbe? Was wäre, wenn Sie einfach eine lokale Datei öffnen, bearbeiten und dann die Änderungen in einer neuen Datei oder wieder in der ursprünglichen Datei speichern könnten? Mit der File System Access API können Sie Dateien und Verzeichnisse öffnen und erstellen sowie sie ändern und speichern.

Wie erkenne ich eine API nach Funktion? Die File System Access API stellt die neue Methode window.chooseFileSystemEntries() bereit. Daher muss ich je nachdem, ob diese Methode verfügbar ist, verschiedene Import- und Exportmodule bedingt laden. Das geht so:

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 mich jedoch mit den Details der File System Access API betone, 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 Skripts. Unten sehen Sie die Netzwerk-Tabs von Firefox und Safari.

Safari Web Inspector zeigt an, wie die alten Dateien geladen werden.
Netzwerktab von Safari Web Inspector.
Firefox-Entwicklertools mit Anzeige der alten Dateien, die geladen werden.
Netzwerk-Tab der Firefox-Entwicklertools.

In Chrome, einem Browser, der die API unterstützt, werden jedoch nur die neuen Skripts geladen. Auf elegante Weise ist dies dank des dynamischen import() möglich, das von allen modernen Browsern unterstützt wird. Wie ich bereits sagte, ist das Gras heutzutage ziemlich grün.

Chrome-Entwicklertools, die zeigen, wie die modernen Dateien geladen werden.
Netzwerktab in den Chrome-Entwicklertools.

File System Access API

Nachdem ich dieses Problem behoben habe, möchte ich mir die tatsächliche Implementierung ansehen, die auf der File System Access API basiert. Zum Importieren eines Bildes rufe ich window.chooseFileSystemEntries() auf und übergebe ein accepts-Attribut für die Bilddatei. Sowohl Dateiendungen als auch MIME-Typen werden unterstützt. Das Ergebnis ist ein Datei-Handle, von dem ich die eigentliche Datei abrufen kann, indem ich getFile() aufrufe.

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);
  }
};

Der Export eines Bildes funktioniert fast genauso, aber dieses Mal muss ich den Typparameter 'save-file' an die Methode chooseFileSystemEntries() übergeben. Daraufhin wird ein Dialogfeld zum Speichern der Datei angezeigt. Bei geöffneter Datei war dies nicht erforderlich, da 'open-file' die Standardeinstellung ist. Ich habe den Parameter accepts ähnlich wie zuvor festgelegt, aber dieses Mal auf PNG-Bilder beschränkt. Ich erhalte wieder einen Datei-Handle, aber statt die Datei abzurufen, erstelle ich dieses Mal einen beschreibbaren Stream, indem ich createWritable() aufrufe. Als Nächstes schreibe ich das Blob, also mein Grußkartenbild, in die Datei. Zum Schluss schließe ich den beschreibbaren Stream.

Es kann immer ein Fehler auftreten: Auf dem Laufwerk könnte nicht genügend Speicherplatz vorhanden sein, ein Schreib- oder Lesefehler aufgetreten sein oder der Nutzer schließt den Dateidialog. Deshalb fasse ich die Aufrufe immer in eine try...catch-Anweisung ein.

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 progressiven Verbesserung mit der File System Access API kann ich eine Datei wie zuvor öffnen. Die importierte Datei wird direkt auf den Canvas gezeichnet. Anschließend speichere ich die Änderungen in einem Dialogfeld, in dem ich den Namen und den Speicherort der Datei auswähle. Jetzt kann die Datei für alle Ewigkeit aufbewahrt werden.

App für Fugu-Begrüßungen mit einem Dialogfeld zum Öffnen der Datei
Dialogfeld zum Öffnen von Dateien.
App für Fugu-Begrüßungen jetzt mit einem importierten Bild.
Das importierte Image.
Fugu-Begrüßungs-App mit dem geänderten Bild
Das geänderte Bild wird in einer neuen Datei gespeichert.

Web Share API und Web Share Target API

Vielleicht möchte ich nicht nur ewig speichern, sondern vielleicht auch meine Grußkarte teilen. Genau das kann ich mit der Web Share API und der Web Share Target API tun. Mobile und neuere Desktop-Betriebssysteme haben integrierte Freigabemechanismen. Unten sehen Sie z. B. das Share Sheet von Safari für den Desktop unter macOS, das durch einen Artikel in meinem Blog ausgelöst 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 Messages App.

Das Desktop-Share-Sheet von Safari unter macOS, das durch die Schaltfläche „Teilen“ eines Artikels ausgelöst wird
Web Share API in der Desktopversion von Safari unter macOS.

Der Code hierfür ist ziemlich einfach. Ich rufe navigator.share() auf und übergebe ein optionales title, text und url in einem Objekt. Aber was ist, wenn ich ein Bild anhängen möchte? In Level 1 der Web Share API wird dies noch nicht unterstützt. Die gute Nachricht ist, dass Web Share Level 2 zusätzliche Funktionen zur Dateifreigabe bietet.

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 vorbereiten, das aus einem Blob, einem title und einem text besteht. Als Best Practice verwende ich als Nächstes die neue navigator.canShare()-Methode, die tut, was ihr Name vorschlägt: Sie sagt mir, ob das data-Objekt, das ich freigeben möchte, technisch vom Browser freigegeben werden kann. Wenn navigator.canShare() mir mitteilt, dass die Daten freigegeben werden können, kann ich wie zuvor navigator.share() anrufen. Da alles fehlschlagen kann, verwende ich auch hier 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 progressive Verbesserung. Wenn sowohl 'share' als auch 'canShare' im Objekt navigator vorhanden sind, gehe ich weiter vor und lade share.mjs über das dynamische import(). In Browsern wie Safari für Mobilgeräte, die nur eine der beiden Bedingungen erfüllen, lade ich die Funktion nicht.

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

Wenn ich in einem Browser wie Chrome unter Android auf die Schaltfläche Teilen tippe, wird die integrierte Freigabetabelle geöffnet. Wenn ich z. B. Gmail auswählen kann, öffnet sich das E-Mail-Composer-Widget mit dem angehängten Bild.

Share Sheet auf Betriebssystemebene mit verschiedenen Apps, mit denen das Image geteilt werden kann.
Eine App auswählen, für die die Datei freigegeben werden soll.
Das Gmail-Widget zum Schreiben von E-Mails mit Bild im Anhang.
Die Datei wird im Composer-Programm von Gmail an eine neue E-Mail angehängt.

Contact Picker API

Als Nächstes möchte ich über Kontakte sprechen, d. h. das Adressbuch eines Geräts oder die Kontaktmanager-App. Wenn man eine Grußkarte schreibt, ist es nicht immer einfach, den Namen einer Person richtig anzugeben. Ich habe zum Beispiel einen Freund von Sergey, der seinen Namen lieber in kyrillischen Buchstaben schreiben möchte. Ich verwende eine deutsche QWERTZ-Tastatur und weiß nicht, wie ich den Namen eintippen soll. Dieses Problem kann mit der Contact Picker API gelöst werden. Da mein Freund in der Kontakte-App meines Smartphones gespeichert ist, 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 möchte ich nur die Namen verwenden, aber für andere Anwendungsfälle könnte ich mich für Telefonnummern, E-Mail-Adressen, Avatarsymbole oder physische Adressen interessieren. Als Nächstes konfiguriere ich ein options-Objekt und setze multiple auf true, damit ich mehr als einen Eintrag auswählen kann. Schließlich kann ich navigator.contacts.select() aufrufen, das die gewünschten Eigenschaften für die vom Nutzer ausgewählten Kontakte zurückgibt.

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);
  }
};

Inzwischen haben Sie wahrscheinlich das Muster gelernt: 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 zur Begrüßung auf die Schaltfläche Kontakte tippe und meine beiden besten Freunde auswähle, Ihre Namen werden dann auf meine Grußkarte gezeichnet.

Kontaktauswahl, in der die Namen von zwei Kontakten im Adressbuch angezeigt werden
Mit der Kontaktauswahl aus dem Adressbuch zwei Namen auswählen.
Die Namen der beiden zuvor ausgewählten Kontakte auf der Grußkarte.
Die beiden Namen werden dann auf die Grußkarte eingefügt.

Die Asynchronous Clipboard API

Als Nächstes folgt das Kopieren und Einfügen. Als Softwareentwickler ist das Kopieren und Einfügen sehr beliebt. Als Grußkartenautor möchte ich das manchmal genauso machen. Vielleicht möchte ich entweder ein Bild in eine Grußkarte einfügen, an der ich arbeite, oder meine Grußkarte kopieren, damit ich sie von einer anderen Stelle aus weiter bearbeiten kann. Die Async Clipboard API unterstützt sowohl Text als auch Bilder. Ich zeige Ihnen, wie ich die Funktion zum Kopieren und Einfügen in die Fugu-Begrüßungs-App hinzugefügt habe.

Um etwas in die Zwischenablage des Systems zu kopieren, muss ich es in die Zwischenablage schreiben. Für die Methode navigator.clipboard.write() wird ein Array von Elementen in der Zwischenablage als Parameter verwendet. Jedes Element in der Zwischenablage ist im Wesentlichen ein Objekt mit einem Blob als Wert und dem Blob-Typ 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 eine Schleife über die Elemente in der Zwischenablage erstellen, die ich durch Aufrufen von navigator.clipboard.read() abgerufen habe. Der Grund dafür ist, dass sich mehrere Elemente in der Zwischenablage in unterschiedlichen Darstellungen in der Zwischenablage befinden können. Jedes Element in der Zwischenablage hat ein types-Feld mit den MIME-Typen der verfügbaren Ressourcen. Ich rufe die Methode getType() des Elements in der Zwischenablage 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 muss man eigentlich nicht sagen. Das mache ich nur, wenn Browser unterstützt werden.

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

Wie funktioniert das in der Praxis? Ich habe ein Bild in der macOS Preview App geöffnet und kopiere es in die Zwischenablage. Wenn ich auf Einfügen klicke, werde ich von der App mit den Fugu-Begrüßungen gefragt, ob sie Text und Bilder in der Zwischenablage sehen darf.

App „Fugu Begrüßung“ mit Berechtigungsaufforderung für die Zwischenablage
Berechtigungsaufforderung für die Zwischenablage.

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

Die macOS Preview App mit einem gerade eingefügten Bild ohne Titel.
Ein in die macOS Preview App eingefügtes Bild.

Badging API

Eine weitere nützliche API ist die Badging API. Als installierbare PWA hat „Fugu Begrüßung“ ein App-Symbol, das Nutzer auf das App-Dock oder den Startbildschirm platzieren können. Eine unterhaltsame und einfache Möglichkeit zur Demonstration der API ist die Verwendung von (ab) in Fugu Begrüßungen als Stiftstrich-Zähler. Ich habe einen Event-Listener hinzugefügt, der den Zähler für Stiftstriche erhöht, wenn das pointerdown-Ereignis eintritt, und dann das aktualisierte Symbollogo festlegt. Wenn der Canvas gelöscht wird, wird der Zähler zurückgesetzt und das Badge wird entfernt.

let strokes = 0;

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

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

Diese Funktion ist eine progressive Verbesserung, daher ist die Ladelogik wie gewohnt.

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

In diesem Beispiel habe ich die Zahlen von eins bis sieben mit einem Stiftstrich pro Zahl gezeichnet. Der Zähler für das Badge auf dem Symbol steht nun bei sieben.

Die Zahlen von eins bis sieben auf der Grußkarte, jeweils mit nur einem Stiftstrich.
Zeichnen Sie die Zahlen von 1 bis 7 mit sieben Stiftstrichen.
Badgesymbol in der Fugu-Begrüßungs-App mit der Zahl 7.
Der Zähler für Stiftstriche in Form des App-Symbollogos.

Die Periodic Background Sync API

Du möchtest jeden Tag mit etwas Neuem beginnen? Eine tolle Funktion der App „Fugu Begrüßungs“ ist, dass sie euch jeden Morgen mit einem neuen Hintergrundbild inspirieren kann, um eure Grußkarte zu starten. Dazu wird die Periodic Background Sync API verwendet.

Der erste Schritt besteht darin, ein regelmäßiges Synchronisierungsereignis in der Service Worker-Registrierung zu register. Er wartet auf ein Synchronisierungs-Tag namens 'image-of-the-day' und hat ein Mindestintervall von einem Tag, sodass der Nutzer alle 24 Stunden ein neues Hintergrundbild abrufen kann.

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);
  }
};

Der zweite Schritt besteht darin, im Service Worker auf das Ereignis periodicsync zu warten. Wenn das Ereignis-Tag 'image-of-the-day' ist, also das zuvor registrierte Tag, wird das Bild des Tages über die Funktion getImageOfTheDay() abgerufen. Das Ergebnis wird an alle Clients weitergegeben, damit diese 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. Dies gilt sowohl für den Clientcode als auch für den Service Worker-Code. In nicht unterstützten Browsern wird keiner von beiden geladen. Beachten Sie, dass ich im Service Worker anstelle eines dynamischen import(), der in einem Service Worker-Kontext noch nicht unterstützt wird, das 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');
}

Wenn Sie in Fugu Begrüßungen auf die Schaltfläche Hintergrund klicken, wird das Bild der Grußkarte des Tages angezeigt, das jeden Tag über die Periodic Background Sync API aktualisiert wird.

App für Fugu-Begrüßungen mit einem neuen Bild des Tages für die Grußkarte
Wenn du auf die Schaltfläche Hintergrund drückst, wird das Bild des Tages angezeigt.

Notification Triggers API

Manchmal braucht es auch mit viel Inspiration einen Anstupser, um eine angefangene Grußkarte zu Ende zu bringen. Diese Funktion wird von der Notification Triggers API aktiviert. Als Nutzer kann ich eine Uhrzeit eingeben, zu der ich daran erinnert werden möchte, dass meine Grußkarte fertig ist. Zu diesem Zeitpunkt erhalte ich eine Benachrichtigung, dass meine Grußkarte wartet.

Nach der Aufforderung zur Eingabe der Zielzeit plant die Anwendung die Benachrichtigung mit showTrigger. Dies kann ein TimestampTrigger mit dem zuvor ausgewählten Zieldatum sein. Die Erinnerungsbenachrichtigung wird lokal ausgelöst. Es ist keine Netzwerk- oder Serverseite 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 alles andere, was ich bisher gezeigt habe, handelt es sich hier um eine progressive Verbesserung, sodass der Code nur bedingt geladen wird.

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

Wenn ich in „Fugu Begrüßungs“ das Kästchen Erinnerung anklicke, werde ich gefragt, wann ich daran erinnert werden möchte, die Begrüßungskarte fertigzustellen.

App für Fugu-Begrüßungen mit einer Aufforderung, in der der Nutzer gefragt wird, wann er daran erinnert werden möchte, die Grußkarte fertigzustellen.
Eine lokale Benachrichtigung so planen, dass sie daran erinnert wird, die Grußkarte fertigzustellen.

Wenn eine geplante Benachrichtigung in „Fugu Begrüßungs“ ausgelöst wird, wird sie wie jede andere Benachrichtigung angezeigt, allerdings war dafür keine Netzwerkverbindung erforderlich.

macOS-Benachrichtigungscenter mit einer ausgelösten Benachrichtigung von Fugu Begrüßungs
Die ausgelöste Benachrichtigung wird im macOS-Benachrichtigungscenter angezeigt.

Die Wake Lock API

Ich möchte auch die Wake Lock API einbinden. Manchmal müssen Sie einfach lange genug auf den Bildschirm blicken, bis Sie von der Inspiration küsst. Das schlimmste, was dann passieren kann, ist das Ausschalten des Bildschirms. Die Wake Lock API kann dies verhindern.

Der erste Schritt besteht darin, mit navigator.wakelock.request method() einen Wakelock zu erhalten. Ich übergebe den String 'screen', um einen Bildschirm-Wakelock zu erhalten. Dann füge ich einen Event-Listener hinzu, der informiert wird, wenn der Wakelock freigegeben wird. Das kann z. B. passieren, wenn sich die Sichtbarkeit des Tabs ändert. Wenn das passiert, kann ich den Wakelock wiederholen, wenn 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. Daher muss ich sie nur laden, wenn der Browser die API unterstützt.

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

In der Fugu-Begrüßung gibt es ein Kästchen Insomnia, das den Bildschirm aktiviert lässt, wenn das Häkchen gesetzt ist.

Wenn das Kästchen „Schlaflosigkeit“ angeklickt ist, bleibt der Bildschirm aktiviert.
Das Kästchen Schlaflosigkeit lässt die App aktiv.

Idle Detection API

Manchmal ist das einfach nutzlos, selbst wenn Sie stundenlang auf den Bildschirm starren, und Ihnen fällt nicht die kleinste Idee ein, was mit Ihrer Grußkarte geschehen soll. Mit der Idle Detection API kann die App die Inaktivitätszeit von Nutzern erkennen. Ist der Nutzer zu lange inaktiv, wird die Anwendung auf den Ausgangszustand zurückgesetzt und der Canvas wird gelöscht. Diese API ist derzeit durch die Berechtigung für Benachrichtigungen geschützt, da viele Anwendungsfälle der Inaktivitätserkennung auf Benachrichtigungen basieren. So wird beispielsweise nur eine Benachrichtigung an ein Gerät gesendet, das der Nutzer gerade aktiv verwendet.

Nachdem ich sichergestellt habe, dass die Berechtigung für Benachrichtigungen gewährt wurde, instanziiere ich den inaktiven Detektor. Ich registriere einen Event-Listener, der auf Inaktivitätsänderungen wartet, zu denen der Nutzer und der Bildschirmstatus gehören. Der Nutzer kann aktiv oder inaktiv sein, der Bildschirm kann entsperrt oder gesperrt werden. Ist der Nutzer inaktiv, wird der Canvas gelöscht. Ich gebe für den inaktiven Detektor einen Schwellenwert von 60 Sekunden ein.

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,
});

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

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

In der Fugu-Begrüßungsanwendung wird der Canvas gelöscht, wenn das Kästchen Sitzungsspezifisch angeklickt ist und der Nutzer zu lange inaktiv ist.

Fugu-Begrüßungs-App mit gelöschtem Canvas, nachdem der Nutzer zu lange inaktiv war.
Wenn das Kästchen Sitzungsspezifisch angeklickt ist und der Nutzer zu lange inaktiv war, wird der Canvas gelöscht.

Closing

Puh, was für eine Fahrt. So viele APIs in nur einer Beispiel-App. Ich zwinge die Nutzer nie dazu, für Funktionen zu zahlen, die ihr Browser nicht unterstützt. Durch Progressive Enhancement stelle ich sicher, dass nur der relevante Code geladen wird. Da Anfragen bei HTTP/2 günstig sind, sollte dieses Muster für viele Anwendungen gut funktionieren, auch wenn Sie vielleicht einen Bundler für sehr große Anwendungen in Betracht ziehen sollten.

Im Bereich „Netzwerk“ der Chrome-Entwicklertools werden nur Anfragen für Dateien mit Code angezeigt, den der aktuelle Browser unterstützt.
Chrome-Entwicklertools: Tab „Network“ (Netzwerk) mit nur Anfragen für Dateien mit Code, den der aktuelle Browser unterstützt.

Die App kann in jedem Browser etwas anders aussehen, da nicht alle Plattformen alle Funktionen unterstützen. Die Hauptfunktion ist jedoch immer vorhanden und wird kontinuierlich gemäß den Funktionen des jeweiligen Browsers optimiert. Diese Funktionen können sich sogar in ein und demselben Browser ändern, je nachdem, ob die Anwendung als installierte Anwendung oder in einem Browsertab ausgeführt wird.

Fugu-Grüße, die in Android Chrome ausgeführt werden und viele verfügbare Funktionen zeigen.
Fugu-Grüße in Android Chrome.
Auf dem Safari-Desktop ausgeführte Fugu-Begrüßungen mit weniger verfügbaren Funktionen
Fugu-Begrüßungen, die in Safari auf dem Desktop ausgeführt werden.
Fugu-Begrüßung, die auf dem Chrome-Desktop läuft und viele verfügbare Funktionen zeigt.
Fugu-Begrüßungen, die im Chrome-Desktop ausgeführt werden.

Wenn Sie sich für die App Fugu Begrüßungs interessieren, finden Sie auf GitHub einen Fork dafür.

Repository mit den Fugu-Begrüßungen auf GitHub
App Fugu Begrüßungs auf GitHub.

Das Chromium-Team arbeitet intensiv daran, die Grünflächen im Hinblick auf fortschrittliche Fugu APIs umweltfreundlicher zu gestalten. Durch progressive Verbesserung bei der Entwicklung meiner App sorge ich dafür, dass alle eine gute und solide Grunderfahrung haben. Gleichzeitig soll dafür gesorgt werden, dass Nutzer von Browsern, die mehr Webplattform-APIs unterstützen, eine noch bessere Erfahrung erhalten. Ich freue mich auf Ihre nächsten Schritte mit der progressiven Verbesserung Ihrer Apps.

Danksagungen

Ich bin Christian Liebel und Hemanth HM dankbar, die beide an den Fugu-Begrüßungen teilgenommen haben. Dieser Artikel wurde von Joe Medley und Kayce Basques geprüft. Jake Archibald hat mir geholfen, die Situation mit dem dynamischen import() in einem Service-Worker-Kontext herauszufinden.