Jenseits von SPAs – alternative Architekturen für Ihre PWA

Lassen Sie uns über Architektur sprechen.

Ich möchte ein wichtiges, aber möglicherweise missverstandenes Thema ansprechen: die Architektur, die Sie für Ihre Web-App verwenden, und insbesondere, wie sich Ihre architektonischen Entscheidungen beim Erstellen einer progressiven Web-App auswirken.

„Architektur“ klingt vielleicht vage und es ist nicht sofort klar, warum das wichtig ist. Eine Möglichkeit, sich mit der Architektur zu befassen, besteht darin, sich die folgenden Fragen zu stellen: Welche HTML-Elemente werden geladen, wenn ein Nutzer eine Seite auf meiner Website besucht? Was wird geladen, wenn sie eine andere Seite besuchen?

Die Antworten auf diese Fragen sind nicht immer einfach und werden noch komplizierter, wenn Sie über progressive Web-Apps nachdenken. Mein Ziel ist es, Ihnen eine mögliche Architektur vorzustellen, die sich als effektiv erwiesen hat. In diesem Artikel bezeichne ich die Entscheidungen, die ich getroffen habe, als „mein Ansatz“ zum Erstellen einer progressiven Web-App.

Sie können meinen Ansatz gerne für Ihre eigene PWA verwenden, aber es gibt immer auch andere gültige Alternativen. Ich hoffe, dass Sie durch die Darstellung der einzelnen Komponenten inspiriert werden und sich in der Lage fühlen, die Lösung an Ihre Bedürfnisse anzupassen.

Stack Overflow-PWA

Zur Ergänzung dieses Artikels habe ich eine Stack Overflow-PWA erstellt. Ich verbringe viel Zeit damit, auf Stack Overflow zu lesen und Beiträge zu leisten. Deshalb wollte ich eine Web-App entwickeln, mit der sich häufig gestellte Fragen zu einem bestimmten Thema ganz einfach aufrufen lassen. Sie basiert auf der öffentlichen Stack Exchange API. Es ist Open Source und weitere Informationen finden Sie im GitHub-Projekt.

Mehrseitige Apps (MPAs)

Bevor ich ins Detail gehe, möchte ich einige Begriffe definieren und die zugrunde liegende Technologie erläutern. Zuerst geht es um das, was ich als „Multi Page Apps“ oder „MPAs“ bezeichne.

MPA ist ein schicker Name für die traditionelle Architektur, die seit Beginn des Webs verwendet wird. Jedes Mal, wenn ein Nutzer zu einer neuen URL navigiert, rendert der Browser nach und nach den HTML-Code für diese Seite. Es wird nicht versucht, den Status der Seite oder die Inhalte zwischen den Navigationsvorgängen beizubehalten. Jedes Mal, wenn Sie eine neue Seite aufrufen, fangen Sie von vorn an.

Das ist anders als beim Single-Page-App-Modell (SPA) zum Erstellen von Web-Apps, bei dem der Browser JavaScript-Code ausführt, um die vorhandene Seite zu aktualisieren, wenn der Nutzer einen neuen Bereich besucht. Sowohl SPAs als auch MPAs sind gleichermaßen gültige Modelle. In diesem Beitrag möchte ich jedoch PWA-Konzepte im Kontext einer mehrseitigen App untersuchen.

Zuverlässig schnell

Sie haben mich (und unzählige andere) den Begriff „progressive Web-App“ oder PWA verwenden hören. Einige der Hintergrundinformationen sind Ihnen möglicherweise bereits von anderen Seiten dieser Website bekannt.

Eine PWA ist eine Web-App, die eine erstklassige Nutzererfahrung bietet und sich einen Platz auf dem Startbildschirm des Nutzers verdient. Das Akronym FIRE, das für Fast (schnell), Integrated (integriert), Reliable (zuverlässig) und Engaging (ansprechend) steht, fasst alle Attribute zusammen, die beim Erstellen einer PWA berücksichtigt werden müssen.

In diesem Artikel konzentriere ich mich auf eine Teilmenge dieser Attribute: Schnell und Zuverlässig.

Schnell: „Schnell“ kann in verschiedenen Kontexten unterschiedliche Bedeutungen haben. Ich werde jedoch auf die Geschwindigkeitsvorteile eingehen, die sich ergeben, wenn so wenig wie möglich aus dem Netzwerk geladen wird.

Zuverlässig:Aber rohe Geschwindigkeit allein reicht nicht. Damit Ihre Web-App als PWA wahrgenommen wird, sollte sie zuverlässig sein. Sie muss so robust sein, dass immer etwas geladen wird, auch wenn es sich nur um eine benutzerdefinierte Fehlerseite handelt, unabhängig vom Zustand des Netzwerks.

Zuverlässig schnell:Zum Schluss möchte ich die PWA-Definition noch einmal leicht umformulieren und darauf eingehen, was es bedeutet, etwas zu entwickeln, das zuverlässig schnell ist. Es reicht nicht aus, nur in einem Netzwerk mit niedriger Latenz schnell und zuverlässig zu sein. Zuverlässig schnell bedeutet, dass die Geschwindigkeit Ihrer Web-App unabhängig von den zugrunde liegenden Netzwerkbedingungen konstant ist.

Aktivierungstechnologien: Service Worker und Cache Storage API

PWAs stellen hohe Anforderungen an Geschwindigkeit und Stabilität. Glücklicherweise bietet die Webplattform einige Bausteine, um diese Art von Leistung zu ermöglichen. Ich beziehe mich auf Service Worker und die Cache Storage API.

Sie können einen Service Worker erstellen, der auf eingehende Anfragen wartet, einige an das Netzwerk weiterleitet und über die Cache Storage API eine Kopie der Antwort für die zukünftige Verwendung speichert.

Ein Service Worker, der die Cache Storage API verwendet, um eine Kopie einer Netzwerkantwort zu speichern.

Wenn die Web-App das nächste Mal dieselbe Anfrage stellt, kann ihr Service Worker die Caches prüfen und einfach die zuvor im Cache gespeicherte Antwort zurückgeben.

Ein Service Worker, der die Cache Storage API verwendet, um zu antworten und das Netzwerk zu umgehen.

Das Netzwerk nach Möglichkeit zu vermeiden, ist ein wichtiger Aspekt, um eine zuverlässig schnelle Leistung zu bieten.

„Isomorphes“ JavaScript

Ein weiteres Konzept, das ich ansprechen möchte, ist das, was manchmal als isomorphes oder universelles JavaScript bezeichnet wird. Einfach ausgedrückt: Derselbe JavaScript-Code kann in verschiedenen Laufzeitumgebungen verwendet werden. Beim Erstellen meiner PWA wollte ich JavaScript-Code zwischen meinem Backend-Server und dem Service Worker freigeben.

Es gibt viele gültige Ansätze, um Code auf diese Weise zu teilen, aber mein Ansatz war, ES-Module als endgültigen Quellcode zu verwenden. Anschließend habe ich diese Module für den Server und den Service Worker mit einer Kombination aus Babel und Rollup transpiliert und gebündelt. In meinem Projekt ist Code in Dateien mit der Dateiendung .mjs Code, der sich in einem ES-Modul befindet.

Der Server

Mit diesen Konzepten und Begriffen im Hinterkopf sehen wir uns nun an, wie ich meine Stack Overflow-PWA erstellt habe. Ich beginne mit unserem Backend-Server und erkläre, wie er in die Gesamtarchitektur passt.

Ich suchte nach einer Kombination aus einem dynamischen Backend und statischem Hosting und entschied mich für die Firebase-Plattform.

Firebase Cloud Functions startet automatisch eine Node-basierte Umgebung, wenn eine eingehende Anfrage vorliegt, und lässt sich in das beliebte Express HTTP-Framework einbinden, mit dem ich bereits vertraut war. Außerdem bietet es Hosting für alle statischen Ressourcen meiner Website. Sehen wir uns an, wie der Server Anfragen verarbeitet.

Wenn ein Browser eine Navigationsanfrage an unseren Server sendet, durchläuft er den folgenden Ablauf:

Übersicht über das serverseitige Generieren einer Navigationsantwort.

Der Server leitet die Anfrage anhand der URL weiter und verwendet die Templating-Logik, um ein vollständiges HTML-Dokument zu erstellen. Ich verwende eine Kombination aus Daten aus der Stack Exchange API sowie partiellen HTML-Fragmenten, die der Server lokal speichert. Sobald unser Service Worker weiß, wie er reagieren soll, kann er mit dem Streamen von HTML zurück zu unserer Web-App beginnen.

Zwei Aspekte dieses Bildes sind es wert, genauer betrachtet zu werden: Routing und Templating.

Routing

Beim Routing habe ich die native Routing-Syntax des Express-Frameworks verwendet. Sie ist flexibel genug, um einfache URL-Präfixe sowie URLs mit Parametern als Teil des Pfads abzugleichen. Hier erstelle ich eine Zuordnung zwischen Routennamen und dem zugrunde liegenden Express-Muster, das abgeglichen werden soll.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

Ich kann dann direkt im Code des Servers auf diese Zuordnung verweisen. Wenn ein bestimmtes Express-Muster übereinstimmt, antwortet der entsprechende Handler mit einer Templating-Logik, die für die übereinstimmende Route spezifisch ist.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), as>ync (req, res) = {
  // Templating logic.
});

Serverseitige Vorlagen

Wie sieht diese Vorlagenlogik aus? Ich habe einen Ansatz gewählt, bei dem partielle HTML-Fragmente nacheinander zusammengesetzt werden. Dieses Modell eignet sich gut für das Streaming.

Der Server sendet sofort ein erstes HTML-Boilerplate zurück und der Browser kann diese Teilseite sofort rendern. Während der Server die restlichen Datenquellen zusammensetzt, streamt er sie an den Browser, bis das Dokument vollständig ist.

Sehen Sie sich dazu den Express-Code für eine unserer Routen an:

app.get(routes.get('index'), async (req>, res) = {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Durch die Verwendung der write()-Methode des response-Objekts und den Verweis auf lokal gespeicherte partielle Vorlagen kann ich den Stream der Antwort sofort starten, ohne eine externe Datenquelle zu blockieren. Der Browser nimmt dieses anfängliche HTML und rendert sofort eine aussagekräftige Benutzeroberfläche und eine Ladebenachrichtigung.

Im nächsten Teil unserer Seite werden Daten aus der Stack Exchange API verwendet. Um diese Daten zu erhalten, muss unser Server eine Netzwerkanfrage stellen. Die Web-App kann erst dann etwas anderes rendern, wenn sie eine Antwort erhält und verarbeitet hat. Nutzer sehen aber zumindest keinen leeren Bildschirm, während sie warten.

Sobald die Webanwendung die Antwort von der Stack Exchange API erhalten hat, ruft sie eine benutzerdefinierte Templating-Funktion auf, um die Daten aus der API in den entsprechenden HTML-Code zu übersetzen.

Vorlagensprache

Die Verwendung von Vorlagen kann ein überraschend kontroverses Thema sein. Mein Ansatz ist nur einer von vielen. Sie sollten Ihre eigene Lösung verwenden, insbesondere wenn Sie an ein vorhandenes Templating-Framework gebunden sind.

Für meinen Anwendungsfall war es sinnvoll, sich nur auf die Template-Literale von JavaScript zu verlassen und einige Logik in Hilfsfunktionen aufzuteilen. Einer der Vorteile beim Erstellen einer MPA ist, dass Sie keine Statusaktualisierungen und kein erneutes Rendern Ihres HTML-Codes im Blick behalten müssen. Ein einfacher Ansatz, der statischen HTML-Code erzeugt, hat für mich funktioniert.

Hier ist ein Beispiel dafür, wie ich den dynamischen HTML-Teil des Index meiner Web-App als Vorlage verwende. Wie bei meinen Routen wird die Templating-Logik in einem ES-Modul gespeichert, das sowohl in den Server als auch in den Service Worker importiert werden kann.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}"< Qu>estions/h3`;
  cons<t form = `form me>tho<d=&qu>ot;GET".../form`;
  const questionCards = i>tems
    .map(item =
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('&<#39;);
  const que>stions = `div id<=&qu>ot;questions"${questionCards}/div`;
  return title + form + questions;
}

Diese Vorlagenfunktionen sind reines JavaScript. Es ist sinnvoll, die Logik bei Bedarf in kleinere Hilfsfunktionen aufzuteilen. Hier übergebe ich jedes der in der API-Antwort zurückgegebenen Elemente an eine solche Funktion, die ein Standard-HTML-Element mit allen entsprechenden Attributen erstellt.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url=>"${<qu>estionUrl(id)}"${title}/a`;
}

Besonders wichtig ist ein Datenattribut, das ich jedem Link hinzufüge, data-cache-url, das auf die Stack Exchange API-URL festgelegt ist, die ich zum Anzeigen der entsprechenden Frage benötige. Das solltest du beachten. Ich werde es mir später noch einmal ansehen.

Zurück zu meinem Routen-Handler: Sobald die Vorlagen fertig sind, streame ich den letzten Teil des HTML-Codes meiner Seite an den Browser und beende den Stream. Dies ist das Signal für den Browser, dass das progressive Rendern abgeschlossen ist.

app.get(routes.get('index'), async (req>, res) = {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Das war ein kurzer Überblick über meine Serverkonfiguration. Nutzer, die meine Web-App zum ersten Mal besuchen, erhalten immer eine Antwort vom Server. Wenn ein Besucher jedoch zu meiner Web-App zurückkehrt, antwortet mein Service Worker. Sehen wir uns das genauer an.

Der Service Worker

Übersicht über das Generieren einer Navigationsantwort im Service Worker.

Dieses Diagramm sollte Ihnen bekannt vorkommen. Viele der Komponenten, die ich bereits behandelt habe, sind hier in einer leicht anderen Anordnung zu sehen. Sehen wir uns den Anfrageablauf unter Berücksichtigung des Service Workers an.

Unser Service Worker verarbeitet eine eingehende Navigationsanfrage für eine bestimmte URL und verwendet wie mein Server eine Kombination aus Routing- und Templating-Logik, um herauszufinden, wie er reagieren soll.

Der Ansatz ist derselbe wie zuvor, aber mit anderen Low-Level-Primitiven wie fetch() und der Cache Storage API. Ich verwende diese Datenquellen, um die HTML-Antwort zu erstellen, die der Service Worker an die Web-App zurückgibt.

Workbox

Anstatt mit Low-Level-Primitiven von Grund auf neu zu beginnen, werde ich meinen Service Worker auf einer Reihe von High-Level-Bibliotheken namens Workbox aufbauen. Sie bietet eine solide Grundlage für die Caching-, Routing- und Antwortgenerierungslogik eines Service Workers.

Routing

Genau wie bei meinem serverseitigen Code muss mein Service Worker wissen, wie eine eingehende Anfrage mit der entsprechenden Antwortlogik abgeglichen wird.

Mein Ansatz war, jede Express-Route in einen entsprechenden regulären Ausdruck zu übersetzen. Dabei habe ich eine hilfreiche Bibliothek namens regexparam verwendet. Nachdem die Übersetzung erfolgt ist, kann ich die integrierte Unterstützung von Workbox für Routing mit regulären Ausdrücken nutzen.

Nachdem ich das Modul mit den regulären Ausdrücken importiert habe, registriere ich jeden regulären Ausdruck beim Router von Workbox. Innerhalb jeder Route kann ich eine benutzerdefinierte Templating-Logik angeben, um eine Antwort zu generieren. Die Vorlagen im Service Worker sind etwas aufwendiger als auf meinem Backend-Server, aber Workbox hilft dabei.

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

Caching statischer Assets

Ein wichtiger Teil der Vorlagen ist, dass meine partiellen HTML-Vorlagen lokal über die Cache Storage API verfügbar sind und auf dem neuesten Stand gehalten werden, wenn ich Änderungen an der Web-App bereitstelle. Die Cache-Wartung kann bei manueller Ausführung fehleranfällig sein. Daher verwende ich Workbox, um Pre-Caching im Rahmen meines Build-Prozesses zu verwalten.

Ich teile Workbox mit, welche URLs vorab gecacht werden sollen, indem ich eine Konfigurationsdatei verwende, die auf das Verzeichnis mit allen lokalen Assets sowie auf eine Reihe von Mustern verweist, die abgeglichen werden sollen. Diese Datei wird automatisch von der Workbox-Befehlszeilenschnittstelle gelesen, die bei jedem Neuerstellen der Website ausgeführt wird.

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox erstellt eine Momentaufnahme des Inhalts jeder Datei und fügt diese Liste von URLs und Revisionen automatisch in meine endgültige Service Worker-Datei ein. Workbox hat jetzt alles, was es braucht, um die vorab im Cache gespeicherten Dateien immer verfügbar und auf dem neuesten Stand zu halten. Das Ergebnis ist eine service-worker.js-Datei, die Folgendes enthält:

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

Für Nutzer, die einen komplexeren Build-Prozess verwenden, bietet Workbox neben der Befehlszeile auch ein webpack-Plugin und ein generisches Node-Modul.

Streaming

Als Nächstes soll der Service Worker das vorab im Cache gespeicherte partielle HTML sofort an die Web-App streamen. Das ist ein wichtiger Aspekt, um „zuverlässig schnell“ zu sein – ich bekomme immer sofort etwas Sinnvolles auf dem Bildschirm angezeigt. Glücklicherweise ist das mit der Streams API in unserem Service Worker möglich.

Vielleicht haben Sie schon von der Streams API gehört. Mein Kollege Jake Archibald schwärmt schon seit Jahren davon. Er traf die kühne Vorhersage, dass 2016 das Jahr der Webstreams werden würde. Die Streams API ist heute genauso leistungsstark wie vor zwei Jahren, aber mit einem entscheidenden Unterschied.

Während Streams damals nur in Chrome unterstützt wurden, wird die Streams API jetzt breiter unterstützt. Insgesamt ist die Situation positiv und mit dem entsprechenden Fallback-Code steht der Verwendung von Streams in Ihrem Service Worker nichts im Wege.

Nun ja, vielleicht gibt es da eine Sache, die Sie davon abhält, nämlich die Frage, wie die Streams API eigentlich funktioniert. Es bietet eine sehr leistungsstarke Reihe von Primitiven. Entwickler, die damit vertraut sind, können komplexe Datenflüsse wie die folgenden erstellen:

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

Die vollständigen Auswirkungen dieses Codes zu verstehen, ist jedoch möglicherweise nicht für jeden geeignet. Anstatt diese Logik zu analysieren, möchte ich lieber über meinen Ansatz für das Service Worker-Streaming sprechen.

Ich verwende einen brandneuen Wrapper auf hoher Ebene, workbox-streams. Damit kann ich sie in einer Mischung aus Streamingquellen übergeben, sowohl aus Caches als auch aus Laufzeitdaten, die möglicherweise aus dem Netzwerk stammen. Workbox koordiniert die einzelnen Quellen und fügt sie zu einer einzigen Streaming-Antwort zusammen.

Außerdem erkennt Workbox automatisch, ob die Streams API unterstützt wird. Wenn dies nicht der Fall ist, wird eine entsprechende Antwort ohne Streaming erstellt. Das bedeutet, dass Sie sich keine Gedanken über das Schreiben von Fallbacks machen müssen, da Streams immer näher an 100% Browserunterstützung heranrücken.

Laufzeit-Caching

Sehen wir uns an, wie mein Service Worker Laufzeitdaten von der Stack Exchange API verarbeitet. Ich nutze die integrierte Unterstützung von Workbox für eine Caching-Strategie vom Typ „Stale-while-revalidate“ sowie das Ablaufdatum, um sicherzustellen, dass der Speicher der Web-App nicht unbegrenzt wächst.

Ich habe zwei Strategien in Workbox eingerichtet, um die verschiedenen Quellen zu verarbeiten, aus denen die Streamingantwort besteht. Mit einigen Funktionsaufrufen und Konfigurationen können wir mit Workbox das erreichen, wofür sonst Hunderte von Zeilen handgeschriebenen Codes erforderlich wären.

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

Bei der ersten Strategie werden Daten gelesen, die vorab im Cache gespeichert wurden, z. B. unsere partiellen HTML-Vorlagen.

Bei der anderen Strategie wird die Caching-Logik „Stale-While-Revalidate“ zusammen mit dem Ablauf des Caches nach dem Prinzip „Least Recently Used“ (am längsten nicht verwendet) implementiert, sobald 50 Einträge erreicht sind.

Jetzt, da ich diese Strategien habe, muss ich Workbox nur noch mitteilen, wie sie verwendet werden sollen, um eine vollständige Streaming-Antwort zu erstellen. Ich übergebe ein Array von Quellen als Funktionen und jede dieser Funktionen wird sofort ausgeführt. Workbox nimmt das Ergebnis aus jeder Quelle und streamt es sequenziell an die Web-App. Die Übertragung wird nur verzögert, wenn die nächste Funktion im Array noch nicht abgeschlossen ist.

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'})>,
  () = cacheStrategy.makeRequest({request: '/navbar.html'}),
  async >({event, url}) = {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, >data.items);
  },
  () = cacheStrategy.makeRequest({request: '/foot.html'}),
]);

Die ersten beiden Quellen sind vorab im Cache gespeicherte partielle Vorlagen, die direkt aus der Cache Storage API gelesen werden. Sie sind also immer sofort verfügbar. So wird sichergestellt, dass unsere Service Worker-Implementierung zuverlässig schnell auf Anfragen reagiert, genau wie mein serverseitiger Code.

Unsere nächste Quellfunktion ruft Daten von der Stack Exchange API ab und verarbeitet die Antwort in das HTML, das die Web-App erwartet.

Die Strategie „Stale-while-revalidate“ bedeutet, dass ich eine zuvor im Cache gespeicherte Antwort für diesen API-Aufruf sofort auf die Seite streamen kann, während der Cacheeintrag „im Hintergrund“ für das nächste Mal aktualisiert wird, wenn er angefordert wird.

Schließlich streame ich eine im Cache gespeicherte Kopie meiner Fußzeile und schließe die letzten HTML-Tags, um die Antwort abzuschließen.

Durch das Teilen von Code bleiben die Dinge synchron

Bestimmte Teile des Service Worker-Codes kommen Ihnen vielleicht bekannt vor. Das partielle HTML und die Templating-Logik, die von meinem Service Worker verwendet werden, sind identisch mit dem, was von meinem serverseitigen Handler verwendet wird. Durch die gemeinsame Nutzung des Codes wird dafür gesorgt, dass Nutzer eine konsistente Erfahrung haben, unabhängig davon, ob sie meine Web-App zum ersten Mal besuchen oder zu einer Seite zurückkehren, die vom Service Worker gerendert wird. Das ist das Schöne an isomorpher JavaScript.

Dynamische, progressive Verbesserungen

Ich habe sowohl den Server als auch den Service Worker für meine PWA durchlaufen, aber es gibt noch einen letzten Logikteil: Auf jeder meiner Seiten wird ein wenig JavaScript ausgeführt, nachdem sie vollständig gestreamt wurden.

Dieser Code verbessert die Nutzerfreundlichkeit, ist aber nicht entscheidend. Die Web-App funktioniert auch ohne ihn.

Metadaten für Seiten

In meiner App wird clientseitiges JavaScript verwendet, um die Metadaten einer Seite basierend auf der API-Antwort zu aktualisieren. Da ich für jede Seite denselben ersten Teil des zwischengespeicherten HTML-Codes verwende, enthält der Header meines Dokuments in der Web-App generische Tags. Durch die Koordination zwischen meinem Templating- und clientseitigen Code kann ich den Titel des Fensters jedoch mithilfe seitenbezogener Metadaten aktualisieren.

Im Rahmen des Vorlagencodes füge ich ein Script-Tag mit dem richtig maskierten String ein.

const metadataScript = `<script>
  self._title = '${escape(item.title)<}';>
/script`;

Sobald meine Seite geladen wurde, lese ich diesen String und aktualisiere den Dokumenttitel.

if (self._title) {
  document.title = unescape(self._title);
}

Wenn Sie andere seitenbezogene Metadaten in Ihrer eigenen Web-App aktualisieren möchten, können Sie genauso vorgehen.

Offline-UX

Die andere progressive Optimierung, die ich hinzugefügt habe, soll auf unsere Offlinefunktionen aufmerksam machen. Ich habe eine zuverlässige PWA entwickelt und möchte, dass Nutzer wissen, dass sie auch offline zuvor besuchte Seiten laden können.

Zuerst verwende ich die Cache Storage API, um eine Liste aller zuvor im Cache gespeicherten API-Anfragen abzurufen, und übersetze diese in eine Liste von URLs.

Erinnern Sie sich an die speziellen Datenattribute, über die ich gesprochen habe? Jedes enthält die URL für die API-Anfrage, die zum Anzeigen einer Frage erforderlich ist. Ich kann diese Datenattribute mit der Liste der im Cache gespeicherten URLs abgleichen und ein Array mit allen Frage-Links erstellen, die nicht übereinstimmen.

Wenn der Browser in den Offlinemodus wechselt, durchlaufe ich die Liste der nicht im Cache gespeicherten Links und blende die Links aus, die nicht funktionieren. Das ist nur ein visueller Hinweis für den Nutzer, was er von diesen Seiten erwarten kann. Ich deaktiviere die Links nicht und hindere den Nutzer nicht daran, zu navigieren.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filte>r(card = {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandle>r = () = {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onli>neHandler = () = {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

Häufige Schwierigkeiten

Ich habe Ihnen jetzt einen Überblick über meinen Ansatz zum Erstellen einer PWA mit mehreren Seiten gegeben. Bei der Entwicklung Ihres eigenen Ansatzes müssen Sie viele Faktoren berücksichtigen. Möglicherweise treffen Sie andere Entscheidungen als ich. Diese Flexibilität ist einer der großen Vorteile der Webentwicklung.

Bei der Entwicklung eigener Architekturkonzepte gibt es einige häufige Fehler, die Sie vermeiden sollten.

Kein vollständiges HTML im Cache speichern

Ich rate davon ab, vollständige HTML-Dokumente im Cache zu speichern. Erstens verschwendet es Speicherplatz. Wenn Ihre Web-App für jede Seite dieselbe grundlegende HTML-Struktur verwendet, werden Kopien desselben Markups immer wieder gespeichert.

Noch wichtiger ist, dass bei einer Änderung der gemeinsamen HTML-Struktur Ihrer Website jede dieser zuvor im Cache gespeicherten Seiten weiterhin das alte Layout verwendet. Stellen Sie sich vor, wie frustrierend es für einen wiederkehrenden Besucher ist, wenn er eine Mischung aus alten und neuen Seiten sieht.

Server-/Service Worker-Drift

Die andere Gefahr, die es zu vermeiden gilt, besteht darin, dass Ihr Server und Ihr Service Worker nicht mehr synchron sind. Mein Ansatz war, isomorphen JavaScript-Code zu verwenden, damit derselbe Code an beiden Stellen ausgeführt wird. Je nach Ihrer vorhandenen Serverarchitektur ist das nicht immer möglich.

Unabhängig davon, welche Architektur Sie wählen, sollten Sie eine Strategie für die Ausführung des entsprechenden Routing- und Templating-Codes auf Ihrem Server und in Ihrem Service Worker haben.

Worst-Case-Szenarien

Uneinheitliches Layout / Design

Was passiert, wenn Sie diese Fallstricke ignorieren? Es sind alle möglichen Fehler möglich, aber das Worst-Case-Szenario ist, dass ein wiederkehrender Nutzer eine im Cache gespeicherte Seite mit einem sehr alten Layout aufruft – vielleicht mit veraltetem Kopfzeilentext oder mit CSS-Klassennamen, die nicht mehr gültig sind.

Worst-Case-Szenario: Fehlerhaftes Routing

Alternativ kann ein Nutzer auf eine URL stoßen, die von Ihrem Server, aber nicht von Ihrem Service Worker verarbeitet wird. Eine Website voller Zombie-Layouts und Sackgassen ist keine zuverlässige PWA.

Tipps für den Erfolg

Aber du bist nicht allein! Die folgenden Tipps können Ihnen helfen, diese Fallstricke zu vermeiden:

Vorlagen- und Routingbibliotheken mit mehrsprachigen Implementierungen verwenden

Verwenden Sie Vorlagen- und Routingbibliotheken mit JavaScript-Implementierungen. Ich weiß, dass nicht jeder Entwickler die Möglichkeit hat, von seinem aktuellen Webserver und seiner aktuellen Vorlagensprache zu migrieren.

Viele beliebte Vorlagen- und Routing-Frameworks sind jedoch in mehreren Sprachen verfügbar. Wenn Sie eine finden, die sowohl mit JavaScript als auch mit der Sprache Ihres aktuellen Servers funktioniert, sind Sie einen Schritt näher daran, Ihren Service Worker und Ihren Server zu synchronisieren.

Sequenzielle statt verschachtelter Vorlagen verwenden

Als Nächstes empfehle ich, eine Reihe sequenzieller Vorlagen zu verwenden, die nacheinander gestreamt werden können. Es ist in Ordnung, wenn in späteren Teilen Ihrer Seite eine kompliziertere Vorlagenlogik verwendet wird, solange Sie den ersten Teil Ihres HTML-Codes so schnell wie möglich streamen können.

Sowohl statische als auch dynamische Inhalte in Ihrem Service Worker im Cache speichern

Für eine optimale Leistung sollten Sie alle wichtigen statischen Ressourcen Ihrer Website vorab im Cache speichern. Sie sollten auch eine Laufzeit-Caching-Logik einrichten, um dynamische Inhalte wie API-Anfragen zu verarbeiten. Wenn Sie Workbox verwenden, können Sie auf bewährten, produktionsreifen Strategien aufbauen, anstatt alles von Grund auf zu implementieren.

Nur im absoluten Ausnahmefall im Netzwerk blockieren

Außerdem sollten Sie das Netzwerk nur dann blockieren, wenn es nicht möglich ist, eine Antwort aus dem Cache zu streamen. Eine zwischengespeicherte API-Antwort sofort anzuzeigen, kann oft zu einer besseren Nutzererfahrung führen, als auf aktuelle Daten zu warten.

Ressourcen