Jenseits von SPAs – alternative Architekturen für Ihre PWA

Sprechen wir über... Architektur?

Ich werde ein wichtiges, aber möglicherweise falsch verstandenes Thema behandeln: die Architektur, die Sie für Ihre Webanwendung verwenden, und insbesondere, wie Ihre architektonischen Entscheidungen beim Erstellen einer progressiven Web-App eine Rolle spielen.

„Architektur“ kann vage klingen, und es ist möglicherweise nicht sofort klar, warum das wichtig ist. Stellen Sie sich zum Beispiel die folgenden Fragen zum Thema Architektur: Welcher HTML-Code wird geladen, wenn ein Nutzer eine Seite auf meiner Website aufruft? Was wird dann geladen, wenn sie eine andere Seite besuchen?

Die Antworten auf diese Fragen sind nicht immer einfach. Wenn Sie einmal über progressive Web-Apps nachdenken, kann es sogar noch komplizierter werden. Mein Ziel ist es also, Sie durch eine mögliche, effektive Architektur zu führen. In diesem Artikel benenne ich die getroffenen Entscheidungen als „mein Ansatz“ zur Erstellung einer progressiven Web-App.

Bei der Erstellung Ihrer eigenen PWA können Sie gerne meinen Ansatz verwenden, aber es gibt immer andere gültige Alternativen. Ich hoffe, dass Sie durch das Zusammenwirken all der Teile inspirieren werden und Sie die Gelegenheit haben, dies an Ihre Bedürfnisse anzupassen.

Stack Overflow-PWA

Ergänzend zu diesem Artikel habe ich eine Stack Overflow-PWA erstellt. Ich habe viel Zeit damit verbracht, Stack Overflow zu lesen und Beiträge zu leisten. Ich wollte eine Webanwendung erstellen, mit der ich einfach häufig gestellte Fragen zu einem bestimmten Thema durchstöbern kann. Sie baut auf der öffentlichen Stack Exchange API auf. Es ist Open Source. Weitere Informationen finden Sie im GitHub-Projekt.

Mehrseitige Apps (MPAs)

Bevor wir ins Detail gehen, definieren wir einige Begriffe und erklären die zugrunde liegende Technologie. Zunächst werde ich mehr als „mehrseitige Apps“ oder „MPAs“ besprechen.

MPA ist ein ausgefallener Name für die traditionelle Architektur, die seit der Anfänge des Webs verwendet wurde. Jedes Mal, wenn ein Nutzer eine neue URL aufruft, rendert der Browser nach und nach den für diese Seite spezifischen HTML-Code. Es wird nicht versucht, den Status der Seite oder den Inhalt zwischen den Navigationen beizubehalten. Jedes Mal, wenn Sie eine neue Seite besuchen, beginnen Sie von vorn.

Dies steht im Gegensatz zum SPA-Modell (Single-Page Application) zum Erstellen von Webanwendungen, 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, aber in diesem Beitrag wollte ich mehr über PWA-Konzepte im Kontext einer mehrseitigen App erfahren.

Zuverlässig und schnell

Ich und unzählige andere Nutzer haben schon von „Progressive Web App“ oder PWA gehört. Einige Hintergrundmaterialien sind Ihnen vielleicht schon an anderer Stelle auf dieser Website bekannt.

Sie können sich eine PWA als Web-App vorstellen, die eine erstklassige Nutzererfahrung bietet und einen Platz auf dem Startbildschirm der Nutzer einnimmt. Das Akronym FIRE steht für Fast, Integated, Reliable und Engaging. Es fasst alle Attribute zusammen, die beim Erstellen einer PWA zu berücksichtigen sind.

In diesem Artikel konzentrieren wir uns auf einen Teil dieser Attribute: Schnell und Zuverlässig.

Schnell: „Schnell“ bedeutet in unterschiedlichen Kontexten unterschiedliche Dinge. Im Folgenden geht es um die Vorteile der Geschwindigkeit, wenn so wenig wie möglich aus dem Netzwerk geladen wird.

Zuverlässig: Die reine Geschwindigkeit reicht jedoch nicht aus. Ihre Web-App sollte zuverlässig sein, damit sie sich wie eine PWA anfühlt. Sie muss stabil genug sein, um immer etwas zu laden, auch wenn es nur eine benutzerdefinierte Fehlerseite ist, unabhängig vom Status des Netzwerks.

Zuverlässig schnell:Am Ende formuliere ich die PWA-Definition etwas um und schaue mir an, was es bedeutet, etwas zu erstellen, das zuverlässig schnell ist. Es reicht nicht aus, nur in einem Netzwerk mit niedriger Latenz schnell und zuverlässig zu sein. Zuverlässige Geschwindigkeit bedeutet, dass die Geschwindigkeit Ihrer Webanwendung unabhängig von den zugrunde liegenden Netzwerkbedingungen konstant ist.

Aktivierung von Technologien: Service Workers + Cache Storage API

PWAs setzen neue Maßstäbe in Bezug auf Geschwindigkeit und Robustheit. Zum Glück bietet die Webplattform einige Bausteine, mit denen diese Art von Leistung Wirklichkeit werden kann. Ich beziehe mich dabei auf Service Worker und die Cache Storage API.

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

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

Wenn die Webanwendung das nächste Mal dieselbe Anfrage sendet, 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 zum Antworten verwendet und das Netzwerk umgeht.

Für eine zuverlässig schnelle Leistung ist es wichtig, das Netzwerk nach Möglichkeit zu vermeiden.

„Isomorphes“ JavaScript

Ein weiteres Konzept, das ich behandeln möchte, ist das manchmal als "isomorphe" oder "universelle" JavaScript bezeichnet. Einfach ausgedrückt: Derselbe JavaScript-Code kann von verschiedenen Laufzeitumgebungen gemeinsam verwendet werden. Als ich meine PWA entwickelt habe, wollte ich JavaScript-Code zwischen meinem Back-End-Server und dem Service Worker teilen.

Es gibt viele gültige Ansätze, um Code auf diese Weise freizugeben, aber mein Ansatz bestand darin, 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 sind Dateien mit der Dateiendung .mjs Code, der sich in einem ES-Modul befindet.

Der Server

Unter Berücksichtigung dieser Konzepte und der Terminologie sehen wir uns jetzt an, wie ich meine Stack Overflow-PWA erstellt habe. Wir beginnen mit unserem Back-End-Server und erklären, wie das in die Gesamtarchitektur passt.

Ich war auf der Suche nach einer Kombination aus dynamischem Back-End und statischem Hosting. Mein Ansatz bestand darin, die Firebase-Plattform zu verwenden.

Firebase Cloud Functions erstellt bei einer eingehenden Anfrage automatisch eine knotenbasierte Umgebung und ist in das beliebte Express-HTTP-Framework eingebunden, mit dem ich bereits vertraut war. Außerdem bietet es vorkonfigurierte Hosting-Funktionen 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 basierend auf der URL weiter und verwendet die Vorlagenlogik, um ein vollständiges HTML-Dokument zu erstellen. Ich verwende eine Kombination aus Daten aus der Stack Exchange API und teilweisen HTML-Fragmenten, die der Server lokal speichert. Sobald der Service Worker weiß, wie er antworten kann, kann er mit dem Streamen des HTML-Codes zurück zu unserer Webanwendung beginnen.

Es gibt zwei Bereiche dieses Bildes, die genauer betrachtet werden sollten: Routing und Vorlagen.

Routen

Beim Routing habe ich die native Routingsyntax des Express-Frameworks verwendet. Es ist flexibel genug, um einfache URL-Präfixe sowie URLs abzugleichen, die Parameter als Teil des Pfads enthalten. Hier erstelle ich eine Zuordnung zwischen Routennamen, die mit dem zugrunde liegenden Express-Muster abgeglichen werden sollen.

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 für ein bestimmtes Express-Muster eine Übereinstimmung vorliegt, antwortet der entsprechende Handler mit einer für die übereinstimmenden Route spezifischen Vorlagenlogik.

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

Serverseitige Vorlagen

Und wie sieht diese Vorlagenlogik aus? Ich habe einen Ansatz gewählt, bei dem Teil-HTML-Fragmente nacheinander zusammengefügt werden. Dieses Modell eignet sich gut für Streaming.

Der Server sendet sofort einen anfänglichen HTML-Standardcode zurück und der Browser kann diesen Teil der Seite sofort rendern. Die übrigen Datenquellen werden vom Server an den Browser gestreamt, bis das Dokument vollständig ist.

Sehen Sie sich zur Veranschaulichung 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();
});

Wenn ich die Methode write() des response-Objekts verwende und lokal gespeicherte Teilvorlagen verweise, kann ich den Antwortstream sofort starten, ohne eine externe Datenquelle zu blockieren. Der Browser verwendet diesen anfänglichen HTML-Code und rendert sofort eine aussagekräftige Oberfläche und die Nachricht.

Im nächsten Teil unserer Seite werden Daten aus der Stack Exchange API verwendet. Diese Daten zu erhalten bedeutet, dass unser Server eine Netzwerkanfrage stellen muss. Die Webanwendung kann erst dann etwas anderes rendern, bis sie eine Antwort erhält und diese verarbeitet. Die Nutzer sehen währenddessen aber nicht auf einen leeren Bildschirm.

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

Vorlagensprache

Vorlagen können ein erstaunlich umstrittenes Thema sein, und ich habe mich nur für einen Ansatz von vielen entschieden. Sie sollten Ihre eigene Lösung ersetzen, insbesondere wenn Sie Legacy-Ansätze zu einem bestehenden Vorlagen-Framework haben.

In meinem Anwendungsfall war es sinnvoll, nur die Vorlagenliterale von JavaScript zu verwenden, wobei eine Logik in Hilfsfunktionen aufgeteilt wurde. Eines der schönen Dinge an der Erstellung einer MPA ist, dass Sie nicht den Überblick über Statusaktualisierungen behalten und Ihren HTML-Code neu rendern müssen. Daher hat sich ein einfacher Ansatz zur Generierung von statischem HTML für mich entschieden.

Hier ist ein Beispiel dafür, wie ich Vorlagen für den dynamischen HTML-Teil des Index meiner Webanwendung erstelle. Wie bei meinen Routen wird die Vorlagenlogik 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)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

Diese Vorlagenfunktionen sind reines JavaScript und es ist nützlich, die Logik gegebenenfalls in kleinere Hilfsfunktionen aufzuteilen. Hier übergebe ich jedes der in der API-Antwort zurückgegebenen Elemente in eine solche Funktion. Dadurch wird ein Standard-HTML-Element erstellt, in dem alle entsprechenden Attribute festgelegt sind.

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

Besonders zu beachten ist ein Datenattribut, das ich jedem Link (data-cache-url) hinzufüge und auf die Stack Exchange API-URL festgelegt ist, die ich zum Anzeigen der entsprechenden Frage benötige. Behalten Sie das im Hinterkopf. Ich komme später noch einmal darauf zurück.

Zurück zu meinem Routen-Handler: Sobald die Vorlagenerstellung abgeschlossen ist, streame ich den letzten Teil des HTML-Codes meiner Seite an den Browser und beende den Stream. Dies ist der Hinweis für den Browser, dass das progressive Rendering 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 Servereinrichtung. Nutzer, die meine Webanwendung zum ersten Mal besuchen, erhalten immer eine Antwort vom Server. Wenn jedoch ein Besucher zu meiner Webanwendung 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 Teile, die ich zuvor behandelt habe, sind hier in einer etwas anderen Anordnung. Gehen wir den Anfrageablauf durch und berücksichtigen dabei den Service Worker.

Unser Service Worker verarbeitet eine eingehende Navigationsanfrage für eine bestimmte URL. Genau wie mein Server verwendet er eine Kombination aus Routing- und Vorlagenlogik, um zu ermitteln, wie geantwortet wird.

Der Ansatz ist der gleiche wie zuvor, jedoch 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 Webanwendung zurückgibt.

Workbox

Anstatt bei null anzufangen, werde ich meinen Service Worker auf einer Reihe von High-Level-Bibliotheken namens Workbox erstellen. Sie bietet eine solide Grundlage für die Logik zur Caching-, Routing- und Antwortgenerierung jedes Service Workers.

Routen

Genau wie bei meinem serverseitigen Code muss mein Service Worker wissen, wie eine eingehende Anfrage der entsprechenden Antwortlogik zugeordnet werden kann.

Mein Ansatz bestand darin, jede Express-Route in einen entsprechenden regulären Ausdruck zu übersetzen und dazu eine hilfreiche Bibliothek namens regexparam zu nutzen. Sobald diese Übersetzung ausgeführt wurde, kann ich die in Workbox integrierte Unterstützung für das Routing für reguläre Ausdrücke nutzen.

Nachdem ich das Modul mit den regulären Ausdrücken importiert habe, registriere ich jeden regulären Ausdruck beim Router von Workbox. In jeder Route kann ich benutzerdefinierte Vorlagenlogik bereitstellen, um eine Antwort zu generieren. Das Erstellen von Vorlagen im Service Worker ist etwas komplizierter als bei meinem Backend-Server, aber Workbox nimmt mir einen Großteil der Arbeit ab.

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

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

Caching statischer Assets

Ein wichtiger Teil der Vorlage besteht darin, dafür zu sorgen, dass meine Teil-HTML-Vorlagen lokal über die Cache Storage API verfügbar sind und auf dem neuesten Stand sind, wenn ich Änderungen an der Webanwendung bereitstelle. Die Cache-Wartung kann bei manueller Ausführung fehleranfällig sein. Daher verwende ich Workbox, um das Precaching im Rahmen des Build-Prozesses zu übernehmen.

Ich teile Workbox mithilfe einer Konfigurationsdatei mit, welche URLs vorab im Cache gespeichert werden sollen. Diese Datei verweist auf das Verzeichnis, das alle meine lokalen Assets und eine Reihe von Mustern enthält, die abgeglichen werden sollen. Diese Datei wird automatisch von der Workbox-Befehlszeile gelesen, die bei jeder Neuerstellung der Website run wird.

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

Workbox erstellt einen Snapshot des Inhalts jeder Datei und fügt diese Liste mit URLs und Überarbeitungen automatisch in meine endgültige Service Worker-Datei ein. Workbox hat jetzt alles, was es braucht, um die vorab im Cache gespeicherten Dateien jederzeit verfügbar und auf dem neuesten Stand zu halten. Das Ergebnis ist eine service-worker.js-Datei, die in etwa 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 Befehlszeilenschnittstelle ein webpack-Plug-in und ein generisches Knotenmodul.

Streaming

Als Nächstes möchte ich, dass der Service Worker diesen vorab im Cache gespeicherten HTML-Teil sofort zurück in die Webanwendung streamt. Das ist ein entscheidender Faktor für „zuverlässige Geschwindigkeit“: Ich sehe immer sofort etwas Wichtiges auf dem Bildschirm. Glücklicherweise ermöglicht die Verwendung der Streams API in unserem Service Worker dies.

Jetzt haben Sie vielleicht schon einmal von der Streams API gehört. Mein Kollege Jake Archibald singt schon seit Jahren mit. Er machte die kühne Vorhersage, dass 2016 das Jahr der Webstreams werden wird. Die Streams API ist heute genauso großartig wie vor zwei Jahren, nur mit einem entscheidenden Unterschied.

Während damals nur Streams von Chrome unterstützt wurden, wird die Streams API jetzt umfassender unterstützt. Insgesamt ist dies positiv, und mit einem geeigneten Fallback-Code können Sie heute nichts davon abhalten, Streams in Ihrem Service Worker zu verwenden.

Nun, es gibt eine Sache, die Sie aufhalten könnte, nämlich die Funktionsweise der Streams API. Es stellt eine sehr leistungsfähige Reihe von Primitiven bereit. 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);
        }
      });
  },
});

Es ist jedoch möglicherweise nicht für jeden etwas von Vorteil, die vollständigen Auswirkungen dieses Codes zu verstehen. Statt diese Logik zu analysieren, möchte ich auf meinen Ansatz für das Service Worker-Streaming eingehen.

Ich verwende einen brandneuen, übergeordneten Wrapper: workbox-streams. Damit kann ich eine Mischung aus Streamingquellen und Laufzeitdaten übergeben, die aus dem Netzwerk stammen könnten. Workbox koordiniert die einzelnen Quellen und fügt sie zu einer einzigen Streamingantwort zusammen.

Darüber hinaus erkennt Workbox automatisch, ob die Streams API unterstützt wird. Wenn dies nicht der Fall ist, erstellt es eine entsprechende Nicht-Streaming-Antwort. Sie müssen sich also keine Gedanken über das Schreiben von Fallbacks machen, da die Streams der Browserunterstützung fast 100% näher kommen.

Laufzeit-Caching

Sehen wir uns an, wie mein Service Worker mit Laufzeitdaten aus der Stack Exchange API umgeht. Ich nutze die integrierte Unterstützung von Workbox für eine Caching-Strategie des Typs "Veralteter Zeitraum" in Kombination mit dem Ablauf, um sicherzustellen, dass der Speicher der Webanwendung nicht grenzenlos wird.

Ich habe in Workbox zwei Strategien für die verschiedenen Quellen der Streamingantwort eingerichtet. Mit ein paar Funktionsaufrufen und Konfigurationen können wir mit Workbox genau das tun, was sonst Hunderte von Zeilen an handgeschriebenem Code gebraucht würden.

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

Die erste Strategie liest Daten, die vorab im Cache gespeichert wurden, wie z. B. unsere partiellen HTML-Vorlagen.

Die andere Strategie implementiert die Caching-Logik für eine veraltete Validierung der Cache-Speicherung sowie den am wenigsten kürzlich verwendeten Cache-Ablauf, sobald 50 Einträge erreicht wurden.

Nachdem ich diese Strategien nun eingerichtet habe, muss ich nur noch Workbox mitteilen, wie damit eine vollständige Streamingantwort erstellt werden soll. Ich übergebe ein Array von Quellen als Funktionen, und jede dieser Funktionen wird sofort ausgeführt. Workbox übernimmt das Ergebnis aus jeder Quelle und streamt es der Reihe nach an die Webanwendung. Dies verzögert sich nur, wenn die nächste Funktion im Array noch nicht abgeschlossen wurde.

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 Teilvorlagen, die direkt aus der Cache Storage API gelesen werden, sodass sie immer sofort verfügbar sind. Dadurch wird sichergestellt, dass unsere Service Worker-Implementierung zuverlässig auf Anfragen reagiert, genau wie bei meinem serverseitigen Code.

Mit unserer nächsten Quellfunktion werden Daten von der Stack Exchange API abgerufen und die Antwort in den HTML-Code verarbeitet, den die Webanwendung erwartet.

Wenn ich eine Antwort für diesen API-Aufruf im Cache gespeichert habe, kann ich sie sofort auf die Seite streamen, während der Cache-Eintrag bei der nächsten Anforderung „im Hintergrund“ aktualisiert wird.

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

Wenn Sie Code freigeben, ist alles synchron

Sie werden feststellen, dass Ihnen bestimmte Teile des Service Worker-Codes bekannt vorkommen. Der von meinem Service Worker verwendete teilweise HTML- und Vorlagenlogik ist mit der Logik meines serverseitigen Handlers identisch. Diese Codefreigabe sorgt für eine einheitliche Nutzererfahrung – ganz gleich, ob sie meine Webanwendung zum ersten Mal besuchen oder zu einer Seite zurückkehren, die vom Service Worker gerendert wurde. Das ist das Schöne an isomorphem JavaScript.

Dynamische, progressive Verbesserungen

Ich habe sowohl den Server als auch den Service Worker für meine PWA kennengelernt. Hier ist noch eine letzte Logik enthalten: Auf jeder meiner Seiten wird kleine Menge JavaScript ausgeführt, nachdem sie vollständig gestreamt wurden.

Dieser Code verbessert schrittweise die Nutzererfahrung, ist aber nicht entscheidend – die Webanwendung funktioniert auch, wenn sie nicht ausgeführt wird.

Metadaten für Seiten

Meine Anwendung verwendet clientseitiges JavaScript, um die Metadaten einer Seite basierend auf der API-Antwort zu aktualisieren. Da ich für jede Seite denselben Anfang des im Cache gespeicherten HTML-Codes verwende, hat die Webanwendung am Ende allgemeine Tags im Dokument-Header. Durch die Koordination der Vorlagen und des clientseitigen Codes kann ich den Titel des Fensters jedoch mit seitenspezifischen Metadaten aktualisieren.

Als Teil des Vorlagencodes füge ich ein Skript-Tag ein, das den korrekt maskierten String enthält.

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

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

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

Wenn Sie weitere seitenspezifische Metadaten in Ihrer eigenen Webanwendung aktualisieren möchten, können Sie dem gleichen Ansatz folgen.

Offline-UX

Die andere von mir hinzugefügte progressive Verbesserung wird dazu verwendet, die Aufmerksamkeit auf unsere Offlinefunktionen zu lenken. Ich habe eine zuverlässige PWA erstellt und möchte Nutzern zeigen, dass sie auch ohne Internetverbindung bereits besuchte Seiten laden können.

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

Erinnern Sie sich an die speziellen Datenattribute, über die ich gesprochen habe, die jeweils die URL für die API-Anfrage enthalten, die zum Anzeigen einer Frage erforderlich ist? Ich kann diese Datenattribute mit der Liste der im Cache gespeicherten URLs vergleichen und ein Array aller Fragelinks erstellen, die nicht übereinstimmen.

Wenn der Browser in den Offlinestatus wechselt, prüfe ich die Liste der nicht zwischengespeicherten Links und blende die Links aus, die nicht funktionieren. Beachte bitte, dass dies nur ein visueller Hinweis für den Nutzer ist, was er von diesen Seiten erwarten kann. Ich deaktiviere die Links nicht oder hindere den Nutzer nicht an der Navigation.

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].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

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

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

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

Häufige Stolperfallen

Jetzt habe ich meinen Ansatz zur Erstellung einer mehrseitigen PWA kennengelernt. Es gibt viele Faktoren, die Sie bei der Entwicklung Ihres eigenen Ansatzes berücksichtigen müssen. Möglicherweise treffen Sie am Ende andere Entscheidungen als ich. Diese Flexibilität ist einer der größten Vorteile von Entwicklungen für das Web.

Wenn Sie Ihre eigenen Architekturentscheidungen treffen, gibt es ein paar häufige Schwierigkeiten, auf die Sie stoßen können. Ich möchte Ihnen einige Ärger ersparen.

Vollständigen HTML-Code nicht im Cache speichern

Wir raten davon ab, vollständige HTML-Dokumente in Ihrem Cache zu speichern. Zum einen verschwende ich Platz. Wenn Ihre Webanwendung für jede ihrer Seiten dieselbe grundlegende HTML-Struktur verwendet, speichern Sie am Ende immer wieder Kopien desselben Markups.

Wenn Sie eine Änderung an der gemeinsam genutzten HTML-Struktur Ihrer Website vornehmen, bleiben alle zuvor im Cache gespeicherten Seiten weiterhin bei Ihrem alten Layout hängen. Stellen Sie sich vor, wie frustrierend ein wiederkehrender Besucher ist, der eine Mischung aus alten und neuen Seiten sieht.

Server-/Service-Worker-Drift

Die andere Gefahr, die Sie vermeiden sollten, besteht darin, dass Ihr Server und Ihr Service Worker nicht mehr synchron sind. Mein Ansatz bestand darin, isomorphes JavaScript zu verwenden, sodass an beiden Stellen derselbe Code ausgeführt wurde. Je nach Serverarchitektur ist das nicht immer möglich.

Unabhängig von den Architekturentscheidungen, die Sie treffen, sollten Sie eine Strategie zur Ausführung des entsprechenden Routing- und Vorlagencodes auf Ihrem Server und Ihrem Service Worker haben.

Worst-Case-Szenarien

Uneinheitliches Layout / Design

Was passiert, wenn Sie diese Fallstricke ignorieren? Es sind zwar alle möglichen Fehler möglich, aber im schlimmsten Fall besucht ein wiederkehrender Nutzer eine im Cache gespeicherte Seite mit einem sehr veralteten Layout, z. B. mit veraltetem Header-Text oder mit nicht mehr gültigen CSS-Klassennamen.

Worst-Case-Szenario: fehlerhaftes Routing

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

Tipps für den Erfolg

Aber das ist nicht allein! Die folgenden Tipps können Ihnen helfen, diese Schwierigkeiten zu vermeiden:

Vorlagen- und Routingbibliotheken mit mehrsprachigen Implementierungen verwenden

Verwenden Sie nach Möglichkeit Vorlagen- und Routingbibliotheken mit JavaScript-Implementierungen. Ich weiß, dass nicht alle Entwickler da draußen den Luxus haben, Ihren aktuellen Webserver und die Vorlagensprache zu migrieren.

Einige beliebte Vorlagen- und Routing-Frameworks haben jedoch Implementierungen in mehreren Sprachen. Wenn Sie einen finden, der sowohl mit JavaScript als auch mit der Sprache Ihres aktuellen Servers funktioniert, sind Sie der Synchronisierung Ihres Service Workers und Ihres Servers einen Schritt näher gekommen.

Sequenzielle statt verschachtelte Vorlagen bevorzugen

Als Nächstes empfehle ich die Verwendung einer Reihe sequenzieller Vorlagen, die nacheinander gestreamt werden können. Für spätere Teile Ihrer Seite ist es in Ordnung, wenn sie eine kompliziertere Vorlagenlogik verwenden. Sie sollten den ersten Teil des HTML-Codes dann so schnell wie möglich streamen können.

Statische und dynamische Inhalte im Service Worker im Cache speichern

Für eine optimale Leistung sollten Sie alle kritischen statischen Ressourcen Ihrer Website vorab im Cache speichern. Außerdem sollten Sie eine Logik für das Laufzeit-Caching einrichten, um dynamische Inhalte wie API-Anfragen zu verarbeiten. Mit Workbox können Sie auf bewährten, produktionsreife Strategien aufbauen, anstatt alles von Grund auf neu zu implementieren.

Netzwerk nur dann blockieren, wenn es absolut notwendig ist

Daher sollten Sie das Netzwerk nur dann blockieren, wenn es nicht möglich ist, eine Antwort aus dem Cache zu streamen. Wenn eine im Cache gespeicherte API-Antwort sofort angezeigt wird, kann dies oft zu einer besseren Nutzererfahrung führen, als auf neue Daten zu warten.

Ressourcen