Schnellere mehrseitige Anwendungen mit Streams

Heutzutage verwenden Websites oder Web-Apps tendenziell eines von zwei Navigationsschemas:

  • Das Navigationsschema wird von Browsern standardmäßig bereitgestellt, d. h., Sie geben eine URL in die Adressleiste des Browsers ein und eine Navigationsanfrage gibt ein Dokument als Antwort zurück. Klicken Sie dann auf einen Link, wodurch das aktuelle Dokument für ein anderes Dokument, ad infinitum, entladen wird.
  • Ein-Seiten-Anwendungsmuster, bei dem eine anfängliche Navigationsanfrage zum Laden der Anwendungs-Shell erforderlich ist. Die Anwendungs-Shell wird auf JavaScript-Code angewiesen, um für jede "Navigation" vom Client gerendertes Markup mit Inhalten aus einer Back-End-API zu füllen.

Die Befürworter der einzelnen Ansätze wurden befürwortet:

  • Das Navigationsschema, das Browser standardmäßig bereitstellen, ist stabil, da für Routen kein JavaScript erforderlich ist. Das Client-Rendering von Markup mithilfe von JavaScript kann ebenfalls ein potenziell teurer Prozess sein, d. h. auf Low-End-Geräten kann es passieren, dass Inhalte verzögert werden, weil das Gerät die Verarbeitungsskripts blockiert, die Inhalte bereitstellen.
  • Andererseits bieten Single-Page-Anwendungen (SPAs) nach dem anfänglichen Laden möglicherweise eine schnellere Navigation. Anstatt sich darauf zu verlassen, dass der Browser ein Dokument für ein ganz neues Dokument entlädt (und dies für jede Navigation wiederholt), können sie eine schnellere und „app-ähnliche“ Erfahrung bieten, selbst wenn dafür JavaScript erforderlich ist.

In diesem Beitrag sprechen wir über eine dritte Methode, mit der sich ein Gleichgewicht zwischen den beiden oben beschriebenen Ansätzen finden lässt: die Verwendung eines Service Worker, der gängige Elemente einer Website vorab im Cache speichert, z. B. das Header- und Footer-Markup, und die Verwendung von Streams, um dem Client so schnell wie möglich eine HTML-Antwort bereitzustellen. Dabei wird weiterhin das Standardnavigationsschema des Browsers verwendet.

Warum HTML-Antworten in einem Service Worker streamen?

Streaming ist etwas, das Ihr Webbrowser bereits tut, wenn er Anfragen stellt. Dies ist im Zusammenhang mit Navigationsanfragen äußerst wichtig, da der Browser nicht daran gehindert wird, auf die gesamte Antwort zu warten, bevor er mit dem Parsen des Dokument-Markups und dem Rendern einer Seite beginnen kann.

Ein Diagramm, das Nicht-Streaming-HTML im Vergleich zu Streaming-HTML zeigt. Im ersten Fall wird die gesamte Markup-Nutzlast erst verarbeitet, wenn sie eintrifft. Im letzteren Fall wird das Markup inkrementell verarbeitet, wenn es in Blöcken aus dem Netzwerk eingeht.

Für Service Worker unterscheidet sich das Streaming ein wenig, da die JavaScript Streams API verwendet wird. Die wichtigste Aufgabe eines Service Workers besteht darin, Anfragen abzufangen und zu beantworten – einschließlich Navigationsanfragen.

Diese Anfragen können auf verschiedene Weise mit dem Cache interagieren. Ein gängiges Caching-Muster für Markups besteht darin, eine Antwort aus dem Netzwerk zuerst zu verwenden, aber auf den Cache zurückzugreifen, wenn eine ältere Kopie verfügbar ist, und optional eine generische Fallback-Antwort bereitzustellen, wenn sich keine verwendbare Antwort im Cache befindet.

Dies ist ein bewährtes Muster für Markups, das gut funktioniert, aber auch wenn es die Zuverlässigkeit im Hinblick auf den Offline-Zugriff erhöht, aber keine inhärenten Leistungsvorteile für Navigationsanfragen bietet, die in erster Linie auf ein Netzwerk oder eine reine Netzwerkstrategie angewiesen sind. Hier kommt Streaming ins Spiel und wir werden uns ansehen, wie Sie das auf der Streams API basierende workbox-streams-Modul in Ihrem Workbox-Service-Worker verwenden können, um Navigationsanfragen auf Ihrer mehrseitigen Website zu beschleunigen.

Eine typische Webseite aufschlüsseln

Strukturell haben Websites meist gemeinsame Elemente, die auf jeder Seite vorhanden sind. Eine typische Anordnung von Seitenelementen sieht oft so aus:

  • Kopfzeile.
  • Inhalt.
  • Fußzeile

Am Beispiel von web.dev sieht diese Aufschlüsselung gängiger Elemente wie folgt aus:

Eine Übersicht über die am häufigsten verwendeten Elemente der web.dev-Website. Die abgegrenzten Gemeinschaftsbereiche sind mit "Kopfzeile", "Inhalt" und "Fußzeile" gekennzeichnet.

Das Ziel bei der Identifizierung von Teilen einer Seite besteht darin, zu bestimmen, was vorab im Cache gespeichert und abgerufen werden kann, ohne das Netzwerk zu verlassen, nämlich das für alle Seiten vorhandene Kopf- und Fußzeilen-Markup, und den Teil der Seite, den wir immer zuerst im Netzwerk aufrufen, in diesem Fall der Inhalt.

Wenn wir wissen, wie Teile einer Seite segmentiert und gemeinsame Elemente identifiziert werden können, können wir einen Service Worker schreiben, der das Kopf- und Fußzeilen-Markup immer sofort aus dem Cache abruft und dabei nur den Inhalt aus dem Netzwerk anfordert.

Anschließend können wir mithilfe der Streams API über workbox-streams alle diese Teile zusammenfügen und sofort auf Navigationsanfragen reagieren. Dabei wird nur das erforderliche Minimum an Markup vom Netzwerk angefordert.

Streaming Service Worker erstellen

Beim Streamen von Teilinhalten in einem Service Worker gibt es viele Umstände, aber jeder Schritt des Prozesses wird im Laufe des Prozesses detailliert erörtert, angefangen mit der Strukturierung Ihrer Website.

Website in Teilbereiche segmentieren

Bevor Sie mit dem Schreiben eines Streaming Service Workers beginnen können, müssen Sie drei Schritte ausführen:

  1. Erstellen Sie eine Datei, die nur das Kopfzeilen-Markup Ihrer Website enthält.
  2. Erstellen Sie eine Datei, die nur das Fußzeilen-Markup Ihrer Website enthält.
  3. Extrahieren Sie den Hauptinhalt jeder Seite in eine separate Datei oder richten Sie Ihr Back-End so ein, dass nur der Seiteninhalt basierend auf einem HTTP-Anfrageheader bereitgestellt wird.

Der letzte Schritt ist der schwierigste, insbesondere wenn Ihre Website statisch ist. In diesem Fall müssen Sie zwei Versionen jeder Seite generieren: Eine Version enthält das vollständige Seiten-Markup, die andere nur den Inhalt.

Streaming Service Worker erstellen

Wenn du das Modul workbox-streams nicht installiert hast, musst du es zusätzlich zu den aktuell installierten Workbox-Modulen installieren. In diesem konkreten Beispiel umfasst das die folgenden Pakete:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

Im nächsten Schritt erstellen Sie den neuen Service Worker und speichern die Kopf- und Fußzeilenabschnitte im Voraus im Cache.

Precaching von Teildateien

Als Erstes erstellen Sie einen Service Worker im Stammverzeichnis Ihres Projekts mit dem Namen sw.js (oder dem gewünschten Dateinamen). Er beginnt mit Folgendem:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Dieser Code bewirkt Folgendes:

  1. Aktiviert das Vorabladen der Navigation für Browser, die es unterstützen.
  2. Speichert das Kopf- und Fußzeilen-Markup vorab im Cache. Das bedeutet, dass das Kopf- und Fußzeilen-Markup für jede Seite sofort abgerufen wird, da es nicht vom Netzwerk blockiert wird.
  3. Statische Assets werden im Platzhalter __WB_MANIFEST vorab im Cache gespeichert, der die Methode injectManifest verwendet.

Streamingantworten

Der größte Teil der Arbeit besteht darin, den Service Worker dazu zu bringen, verkettete Antworten zu streamen. Dennoch machen die Workbox und das zugehörige workbox-streams-Programm eine viel knackige Angelegenheit, als wenn du all das allein machen müsstest:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Dieser Code besteht aus drei Hauptteilen, die die folgenden Anforderungen erfüllen:

  1. Die Strategie NetworkFirst wird verwendet, um Anfragen für Teile von Inhalten zu verarbeiten. Bei dieser Strategie wird der benutzerdefinierte Cache-Name content angegeben, der die Inhaltsteilinhalte enthält, sowie ein benutzerdefiniertes Plug-in, das festlegt, ob ein X-Content-Mode-Anfrageheader für Browser festgelegt werden soll, die das Vorabladen der Navigation nicht unterstützen und daher keinen Service-Worker-Navigation-Preload-Header senden. Dieses Plug-in ermittelt auch, ob die letzte im Cache gespeicherte Version eines Inhalts teilweise oder eine Offline-Fallback-Seite gesendet werden soll, falls keine im Cache gespeicherte Version für die aktuelle Anfrage gespeichert ist.
  2. Die Methode strategy in workbox-streams (hier als composeStrategies bezeichnet) wird verwendet, um die vorab im Cache gespeicherten Kopf- und Fußzeilenteile mit dem vom Netzwerk angeforderten Teil des Inhalts zu verketten.
  3. Das gesamte Schema wird über registerRoute für Navigationsanfragen konfiguriert.

Mit dieser Logik haben wir Streamingantworten eingerichtet. Möglicherweise müssen Sie jedoch ein Back-End durchführen, um sicherzustellen, dass der Content aus dem Netzwerk eine Teilseite ist, die Sie mit den vorab im Cache gespeicherten Teilen zusammenführen können.

Wenn Ihre Website ein Back-End hat

Wenn das Vorabladen der Navigation aktiviert ist, sendet der Browser einen Service-Worker-Navigation-Preload-Header mit dem Wert true. Im Codebeispiel oben haben wir jedoch einen benutzerdefinierten Header X-Content-Mode gesendet, wenn das Vorabladen der Ereignisnavigation in einem Browser nicht unterstützt wird. Im Back-End ändern Sie die Antwort entsprechend dem Vorhandensein dieser Header. In einem PHP-Back-End könnte dies für eine bestimmte Seite in etwa so aussehen:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

Im obigen Beispiel werden die Inhaltsteilwerte als Funktionen aufgerufen, die den Wert von $isPartial verwenden, um zu ändern, wie die Inhaltsteile gerendert werden. So kann die Renderer-Funktion content möglicherweise nur bestimmte Markups in Bedingungen enthalten, wenn sie als Teil abgerufen werden. Auf dieses Thema wird gleich eingegangen.

Wissenswertes

Bevor Sie einen Service Worker zum Streamen und Zusammenfügen von Teilabschnitten bereitstellen, müssen Sie einige Dinge beachten. Es stimmt zwar, dass durch die Verwendung eines Service Workers auf diese Weise das Standardnavigationsverhalten des Browsers nicht grundlegend geändert wird, aber es gibt wahrscheinlich einige Dinge, die Sie beheben müssen.

Seitenelemente beim Navigieren aktualisieren

Das Schwierigste dabei ist, dass einige Dinge auf dem Client aktualisiert werden müssen. Wenn Header-Markups vorab im Cache gespeichert werden, hat die Seite beispielsweise den gleichen Inhalt im <title>-Element. Selbst die Verwaltung des Ein/Aus-Status für Navigationselemente muss bei jeder Navigation aktualisiert werden. Diese und andere Dinge müssen möglicherweise für jede Navigationsanforderung auf dem Client aktualisiert werden.

Sie können das umgehen, indem Sie ein Inline-<script>-Element im Inhaltsteil des Netzwerks platzieren, um einige wichtige Dinge zu aktualisieren:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

Dies ist nur ein Beispiel dafür, wie Sie vorgehen müssen, wenn Sie sich für diese Service Worker-Konfiguration entscheiden. Bei komplexeren Anwendungen mit Nutzerinformationen müssen Sie beispielsweise möglicherweise einige relevante Daten in einem Web Store wie localStorage speichern und die Seite von dort aus aktualisieren.

Umgang mit langsamen Netzwerken

Ein Nachteil von Streamingantworten mit Markup aus dem Precache kann auftreten, wenn die Netzwerkverbindungen langsam sind. Das Problem besteht darin, dass das Header-Markup aus dem Precache sofort ankommt, der Content-Teil aus dem Netzwerk jedoch nach dem anfänglichen Painting des Header-Markups einige Zeit dauert.

Dies kann die Nutzererfahrung verwirren. Wenn die Netzwerke sehr langsam sind, kann es sogar den Eindruck erwecken, dass die Seite nicht mehr richtig dargestellt wird und nicht mehr gerendert wird. In solchen Fällen können Sie im Markup des Inhaltsteils ein Ladesymbol oder eine Nachricht einfügen, die Sie ausblenden können, sobald der Inhalt geladen wurde.

Eine Möglichkeit dafür ist CSS. Angenommen, der Teil der Kopfzeile endet mit einem öffnenden <article>-Element, das leer ist, bis der Teil des Inhalts eingeblendet wird. Sie könnten eine CSS-Regel wie diese schreiben:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Das funktioniert, aber unabhängig von der Netzwerkgeschwindigkeit wird auf dem Client eine Lademeldung angezeigt. Wenn Sie eine ungewöhnliche Informationsflut vermeiden möchten, können Sie diesen Ansatz ausprobieren, bei dem wir den Selektor im obigen Snippet in einer slow-Klasse verschachteln:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Hier können Sie JavaScript im Header-Teil verwenden, um den effektiven Verbindungstyp (zumindest in Chromium) zu lesen und die slow-Klasse bei ausgewählten Verbindungstypen zum <html>-Element hinzuzufügen:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

Dadurch wird sichergestellt, dass effektive Verbindungstypen, die langsamer als der Typ 4g sind, eine Lademeldung erhalten. Anschließend können Sie im Inhaltsteil ein Inline-<script>-Element einfügen, um die Klasse slow aus dem HTML-Code zu entfernen und die Ladenachricht zu entfernen:

<script>
  document.documentElement.classList.remove('slow');
</script>

Fallback-Antwort bereitstellen

Nehmen wir an, du nutzt für deine Inhaltsabschnitte eine netzwerkorientierte Strategie. Wenn der Nutzer offline ist und eine Seite aufruft, die er bereits besucht hat, ist er abgedeckt. Wenn sie jedoch eine Seite aufrufen, die sie noch nicht besucht haben, erhalten sie nichts. Um dies zu vermeiden, müssen Sie eine Fallback-Antwort bereitstellen.

Der Code, der zum Erreichen einer Fallback-Antwort erforderlich ist, wird in früheren Codebeispielen demonstriert. Dieser Vorgang umfasst zwei Schritte:

  1. Offline-Fallback-Antworten vorab im Cache speichern
  2. Richte im Plug-in einen handlerDidError-Callback für deine netzwerkorientierte Strategie ein, um den Cache nach der zuletzt aufgerufenen Version einer Seite zu prüfen. Wenn auf die Seite nie zugegriffen wurde, musst du die matchPrecache-Methode aus dem workbox-precaching-Modul verwenden, um die Fallback-Antwort aus dem Precache abzurufen.

Caching und CDNs

Wenn Sie dieses Streamingmuster in Ihrem Service Worker verwenden, prüfen Sie, ob Folgendes auf Ihre Situation zutrifft:

  • Sie verwenden ein CDN oder eine andere Art von Zwischen-/öffentlichen Cache.
  • Sie haben einen Cache-Control-Header mit einer max-age- und/oder s-maxage-Anweisung(en) ungleich null in Kombination mit der public-Anweisung angegeben.

Wenn beides bei Ihnen der Fall ist, kann der zwischengeschaltete Cache Antworten für Navigationsanfragen enthalten. Beachten Sie jedoch, dass bei Verwendung dieses Musters für jede URL zwei verschiedene Antworten ausgegeben werden können:

  • Die vollständige Antwort, die das Header-, Content- und Footer-Markup enthält.
  • Die Teilantwort, die nur den Inhalt enthält.

Dies kann zu unerwünschtem Verhalten führen, was zu doppelten Header- und Footer-Markups führt, da der Service Worker unter Umständen eine vollständige Antwort aus dem CDN-Cache abruft und diese mit Ihrem vorab im Cache gespeicherten Kopf- und Fußzeilen-Markup kombiniert.

Sie müssen sich auf den Vary-Header verlassen, um dieses Problem zu umgehen. Er wirkt sich auf das Caching-Verhalten aus, indem Cache-fähige Antworten einem oder mehreren Headern in der Anfrage zugeordnet werden. Da wir die Antworten auf Navigationsanfragen basierend auf den Anfrageheadern Service-Worker-Navigation-Preload und X-Content-Mode variieren, müssen wir diesen Vary-Header in der Antwort angeben:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

Mit diesem Header unterscheidet der Browser zwischen vollständigen und teilweisen Antworten bei Navigationsanfragen. So werden Probleme mit doppeltem Header- und Footer-Markup sowie etwaigen Zwischen-Caches vermieden.

Das Ergebnis

Bei den meisten Empfehlungen zur Ladezeit geht es meist nur darum, den Nutzern zu zeigen, was sie haben. Geben Sie sich keine Sorgen und warten Sie nicht, bis Sie alles haben, bevor Sie dem Nutzer etwas zeigen.

Jake Archibald in Fun Hacks for Faster Content

Browser sind besonders gut bei der Verarbeitung von Antworten auf Navigationsanforderungen, selbst bei sehr umfangreichen HTML-Antworttexten. Standardmäßig streamen und verarbeiten Browser das Markup schrittweise und verarbeiten es in Blöcken, um lange Aufgaben zu vermeiden, was sich positiv auf die Startleistung auswirkt.

Das ist unser Vorteil, wenn wir ein Streaming Service Worker-Muster verwenden. Wenn Sie von Anfang an auf eine Anfrage aus dem Service Worker-Cache antworten, kommt der Start der Antwort fast augenblicklich ein. Wenn Sie vorab im Cache gespeicherte Kopf- und Fußzeilen-Markups mit einer Antwort aus dem Netzwerk zusammenführen, profitieren Sie von einigen erheblichen Leistungsvorteilen:

  • Time to First Byte (TTFB) wird oft stark reduziert, da das erste Byte der Antwort auf eine Navigationsanfrage sofort erfolgt.
  • First Contentful Paint (FCP) geht sehr schnell, da das vorab im Cache gespeicherte Header-Markup einen Verweis auf ein im Cache gespeichertes Stylesheet enthält. Das bedeutet, dass die Seite sehr schnell gerendert wird.
  • In einigen Fällen kann auch der Largest Contentful Paint (LCP) schneller ausgeführt werden, insbesondere wenn das größte Bildschirmelement vom vorab im Cache gespeicherten Header-Teil bereitgestellt wird. Dennoch kann die Bereitstellung von etwas aus dem Service Worker-Cache so schnell wie möglich in Verbindung mit kleineren Markup-Nutzlasten zu einem besseren LCP führen.

Das Einrichten und Iterieren von mehrseitigen Streaming-Architekturen kann etwas schwierig sein, aber die damit verbundene Komplexität ist theoretisch oft nicht schwieriger als SPAs. Der Hauptvorteil besteht darin, dass Sie das Standardnavigationsschema des Browsers nicht ersetzen, sondern es optimieren.

Noch besser: Workbox macht diese Architektur nicht nur möglich, sondern einfacher, als wenn Sie sie alleine implementieren würden. Probieren Sie es einfach auf Ihrer eigenen Website aus und finden Sie heraus, wie viel schneller Ihre mehrseitige Website für Nutzer in diesem Bereich schneller sein kann.

Ressourcen