Einen einfachen Webauftritt offline funktionsfähig machen mit ServiceWorker

Avatar of Nicolas Bevacqua
Nicolas Bevacqua am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Ich habe mich in letzter Zeit viel mit ServiceWorker beschäftigt. Als Chris mich bat, einen Artikel darüber zu schreiben, hätte ich mich nicht mehr freuen können. ServiceWorker ist die wirkungsvollste moderne Webtechnologie seit Ajax. Es ist eine API, die im Browser lebt und zwischen Ihren Webseiten und Ihren Anwendungsservern sitzt. Nach der Installation und Aktivierung kann ein ServiceWorker programmatisch bestimmen, wie auf Anfragen nach Ressourcen aus Ihrem Ursprung reagiert werden soll, auch wenn der Browser offline ist. ServiceWorker kann zur Unterstützung des sogenannten „Offline First“-Webs verwendet werden.

ServiceWorker ist eine progressive Technologie, und in diesem Artikel zeige ich Ihnen, wie Sie eine Website so aufbereiten, dass sie für Benutzer mit modernen Browsern offline verfügbar ist, während Benutzer mit nicht unterstützten Browsern unbeeinflusst bleiben.

Hier ist ein stummgeschaltetes, 26 Sekunden langes Video eines unterstützenden Browsers (Chrome), der offline geht und die finale Demo-Website, die trotzdem funktioniert

Wenn Sie sich nur den Code ansehen möchten, gibt es ein Simple Offline Site Repository, das wir dafür erstellt haben. Sie können das Ganze als CodePen Project ansehen, und es ist sogar eine vollwertige Demo-Website.

Browser-Unterstützung

Heute hat ServiceWorker Browserunterstützung in Google Chrome, Opera und in Firefox hinter einem Konfigurationsschalter. Microsoft wird wahrscheinlich bald daran arbeiten. Von Apples Safari gibt es noch keine offizielle Stellungnahme.

Jake Archibald hat eine Seite, die die Unterstützung aller ServiceWorker-bezogenen Technologien verfolgt.

Diese Daten zur Browserunterstützung stammen von Caniuse, wo es weitere Details gibt. Eine Zahl bedeutet, dass der Browser die Funktion ab dieser Version unterstützt.

Desktop

ChromeFirefoxIEEdgeSafari
4544Nein1711.1

Mobil / Tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
12712712711.3-11.4

Angesichts der Tatsache, dass Sie diese Dinge im Stil der progressiven Verbesserung implementieren können (sie beeinträchtigen nicht unterstützte Browser), ist dies eine großartige Gelegenheit, sich einen Vorsprung zu verschaffen. Diejenigen, die unterstützt werden, werden es sehr zu schätzen wissen.

Bevor wir beginnen, möchte ich Sie auf ein paar Dinge hinweisen, die Sie berücksichtigen sollten.

Nur sichere Verbindungen

Sie sollten wissen, dass es bei ServiceWorker einige strenge Voraussetzungen gibt. In erster Linie **muss Ihre Website über eine sichere Verbindung bereitgestellt werden**. Wenn Sie Ihre Website noch über HTTP bereitstellen, ist dies vielleicht ein guter Anlass, HTTPS zu implementieren.

HTTPS ist für ServiceWorker erforderlich

Sie könnten einen CDN-Proxy wie CloudFlare verwenden, um den Traffic sicher zu bedienen. Denken Sie daran, gemischte Inhaltwarnungen zu finden und zu beheben, da einige Browser Ihre Kunden möglicherweise vor Ihrer unsicheren Website warnen.

Obwohl die Spezifikation für HTTP/2 verschlüsselte Verbindungen nicht an sich erzwingt, beabsichtigen Browser, HTTP/2 und ähnliche Technologien *nur* über HTTPS zu implementieren. Die ServiceWorker-Spezifikation hingegen empfiehlt die Browserimplementierung über HTTPS. Browser haben auch angedeutet, über unverschlüsselte Verbindungen bereitgestellte Websites als unsicher zu markieren. Suchmaschinen bestrafen unverschlüsselte Ergebnisse.

„Nur HTTPS“ ist die Art, wie Browser sagen: *„Das ist wichtig, das sollten Sie tun.“*

Eine Promise-basierte API

Die Zukunft von Webbrowser-API-Implementierungen ist stark Promise-lastig. Die fetch-API zum Beispiel streut süßen Promise-basierten Zucker über XMLHttpRequest. ServiceWorker nutzt fetch gelegentlich, aber es gibt auch Worker-Registrierung, Caching und Nachrichtenübermittlung, die alle Promise-basiert sind.

Ob Sie nun ein Fan von Promises sind oder nicht, sie werden bleiben, also gewöhnen Sie sich besser daran.

Registrieren Ihres ersten ServiceWorkers

Ich habe mit Chris an der denkbar einfachsten praktischen Demonstration der Verwendung von ServiceWorker gearbeitet. Er implementierte eine einfache Website (statisches HTML, CSS, JavaScript und Bilder) und bat mich, Offline-Unterstützung hinzuzufügen. Ich hatte das Gefühl, das wäre eine großartige Gelegenheit, zu zeigen, wie einfach und unaufdringlich es ist, einer bestehenden Website Offline-Funktionen hinzuzufügen.

Wenn Sie zum Ende springen möchten, sehen Sie sich diesen Commit zur Demo-Website auf GitHub an.

Der erste Schritt ist die Registrierung des ServiceWorkers. Anstatt blind die Registrierung zu versuchen, stellen wir mittels Feature-Detection fest, ob ServiceWorker verfügbar ist.

if ('serviceWorker' in navigator) {

}

Das folgende Codebeispiel zeigt, wie wir einen ServiceWorker installieren würden. Die JavaScript-Ressource, die an .register übergeben wird, wird im Kontext eines ServiceWorkers ausgeführt. Beachten Sie, wie die Registrierung ein Promise zurückgibt, damit Sie verfolgen können, ob die ServiceWorker-Registrierung erfolgreich war oder nicht. Ich habe den Log-Anweisungen CLIENT: vorangestellt, um es mir visuell zu erleichtern, zu erkennen, ob eine Log-Anweisung von einer Webseite oder dem ServiceWorker-Skript stammt.

// ServiceWorker is a progressive technology. Ignore unsupported browsers
if ('serviceWorker' in navigator) {
  console.log('CLIENT: service worker registration in progress.');
  navigator.serviceWorker.register('/service-worker.js').then(function() {
    console.log('CLIENT: service worker registration complete.');
  }, function() {
    console.log('CLIENT: service worker registration failure.');
  });
} else {
  console.log('CLIENT: service worker is not supported.');
}

Der Endpunkt zur service-worker.js-Datei ist sehr wichtig. Wenn das Skript beispielsweise von /js/service-worker.js geladen würde, könnte der ServiceWorker nur Anfragen im /js/-Kontext abfangen, wäre aber blind für Ressourcen wie /other. Dies ist typischerweise ein Problem, da Sie Ihre JavaScript-Dateien normalerweise in einem Verzeichnis wie /js/, /public/, /assets/ oder ähnlich gruppieren, während Sie in den meisten Fällen das ServiceWorker-Skript vom Stammverzeichnis der Domäne aus bedienen möchten.

Das war tatsächlich die einzig notwendige Änderung an Ihrem Webanwendungscode, vorausgesetzt, Sie hatten bereits HTTPS implementiert. An diesem Punkt werden unterstützende Browser eine Anfrage für /service-worker.js ausgeben und versuchen, den Worker zu installieren.

Wie sollte die service-worker.js-Datei dann strukturiert sein?

Einen ServiceWorker zusammenstellen

ServiceWorker ist ereignisgesteuert und **Ihr Code sollte darauf abzielen, zustandslos zu sein**. Das liegt daran, dass ein ServiceWorker, wenn er nicht verwendet wird, heruntergefahren wird und seinen gesamten Zustand verliert. Sie haben keine Kontrolle darüber, daher ist es am besten, langfristige Abhängigkeiten vom In-Memory-Zustand zu vermeiden.

Unten habe ich die bemerkenswertesten Ereignisse aufgelistet, die Sie in einem ServiceWorker behandeln müssen.

  • Das install-Ereignis wird ausgelöst, wenn ein ServiceWorker zum ersten Mal heruntergeladen wird. Dies ist Ihre Chance, den ServiceWorker-Cache mit den grundlegenden Ressourcen zu befüllen, die auch dann verfügbar sein sollten, wenn Benutzer offline sind.
  • Das fetch-Ereignis wird ausgelöst, wenn eine Anfrage aus dem Geltungsbereich Ihres ServiceWorkers stammt, und Sie haben die Möglichkeit, die Anfrage abzufangen und sofort zu beantworten, ohne das Netzwerk zu kontaktieren.
  • Das activate-Ereignis wird nach einer erfolgreichen Installation ausgelöst. Sie können es verwenden, um ältere Versionen des Workers auszumustern. Wir werden ein einfaches Beispiel betrachten, bei dem wir veraltete Cache-Einträge gelöscht haben.

Gehen wir jedes Ereignis durch und betrachten Beispiele, wie sie behandelt werden könnten.

Installation Ihres ServiceWorkers

Eine Versionsnummer ist nützlich, wenn Sie die Worker-Logik aktualisieren, und ermöglicht es Ihnen, veraltete Cache-Einträge während des Aktivierungsschritts zu entfernen, wie wir später sehen werden. Wir werden die folgende Versionsnummer als Präfix verwenden, wenn wir Cache-Speicher erstellen.

var version = 'v1::';

Sie können addEventListener verwenden, um einen Event-Handler für das install-Ereignis zu registrieren. Die Verwendung von event.waitUntil blockiert den Installationsprozess für das bereitgestellte p-Promise. Wenn das Promise abgelehnt wird, weil beispielsweise eine der Ressourcen nicht heruntergeladen werden konnte, wird der ServiceWorker nicht installiert. Hier können Sie das Promise nutzen, das von caches.open(name) zurückgegeben wird, und es dann in cache.addAll(resources) umwandeln, was Antworten für die angegebenen Ressourcen herunterlädt und speichert.

self.addEventListener("install", function(event) {
  console.log('WORKER: install event in progress.');
  event.waitUntil(
    /* The caches built-in is a promise-based API that helps you cache responses,
       as well as finding and deleting them.
    */
    caches
      /* You can open a cache by name, and this method returns a promise. We use
         a versioned cache name here so that we can remove old cache entries in
         one fell swoop later, when phasing out an older service worker.
      */
      .open(version + 'fundamentals')
      .then(function(cache) {
        /* After the cache is opened, we can fill it with the offline fundamentals.
           The method below will add all resources we've indicated to the cache,
           after making HTTP requests for each of them.
        */
        return cache.addAll([
          '/',
          '/css/global.css',
          '/js/global.js'
        ]);
      })
      .then(function() {
        console.log('WORKER: install completed');
      })
  );
});

Sobald der Installationsschritt erfolgreich war, wird das activate-Ereignis ausgelöst. Dies hilft uns, ältere ServiceWorker auszumustern, und wir werden uns das später ansehen. Konzentrieren wir uns jetzt auf das fetch-Ereignis, das etwas interessanter ist.

Abfangen von Fetch-Anfragen

Das fetch-Ereignis wird ausgelöst, wenn eine von diesem ServiceWorker kontrollierte Seite eine Ressource anfordert. Dies beschränkt sich nicht nur auf fetch oder sogar XMLHttpRequest. Es umfasst sogar die Anfrage nach der HTML-Seite beim ersten Laden, sowie JS- und CSS-Ressourcen, Schriftarten, Bilder usw. Beachten Sie auch, dass Anfragen an andere Ursprünge ebenfalls vom fetch-Handler des ServiceWorkers abgefangen werden. Anfragen an i.imgur.com – dem CDN einer beliebten Bild-Hosting-Seite – würden beispielsweise ebenfalls von unserem ServiceWorker abgefangen, solange die Anfrage von einem der vom Worker kontrollierten Clients (z.B. Browser-Tabs) stammt.

Genau wie bei install können wir das fetch-Ereignis blockieren, indem wir ein Promise an event.respondWith(p) übergeben, und wenn das Promise erfüllt ist, wird der Worker damit statt der Standardaktion (Netzwerkanfrage) antworten. Wir können caches.match verwenden, um nach gecachten Antworten zu suchen und diese zurückzugeben, anstatt das Netzwerk zu kontaktieren.

Wie in den Kommentaren beschrieben, verwenden wir hier ein "eventuell frisches" Caching-Muster, bei dem wir die im Cache gespeicherten Daten zurückgeben, aber immer versuchen, eine Ressource erneut aus dem Netzwerk abzurufen, um den Cache aktuell zu halten. Wenn die dem Benutzer bereitgestellte Antwort veraltet ist, erhält er beim nächsten Anfordern der Ressource eine frische Antwort. Wenn die Netzwerkanfrage fehlschlägt, wird versucht, die Antwort durch eine hartkodierte Response wiederherzustellen.

self.addEventListener("fetch", function(event) {
  console.log('WORKER: fetch event in progress.');

  /* We should only cache GET requests, and deal with the rest of method in the
     client-side, by handling failed POST,PUT,PATCH,etc. requests.
  */
  if (event.request.method !== 'GET') {
    /* If we don't block the event as shown below, then the request will go to
       the network as usual.
    */
    console.log('WORKER: fetch event ignored.', event.request.method, event.request.url);
    return;
  }
  /* Similar to event.waitUntil in that it blocks the fetch event on a promise.
     Fulfillment result will be used as the response, and rejection will end in a
     HTTP response indicating failure.
  */
  event.respondWith(
    caches
      /* This method returns a promise that resolves to a cache entry matching
         the request. Once the promise is settled, we can then provide a response
         to the fetch request.
      */
      .match(event.request)
      .then(function(cached) {
        /* Even if the response is in our cache, we go to the network as well.
           This pattern is known for producing "eventually fresh" responses,
           where we return cached responses immediately, and meanwhile pull
           a network response and store that in the cache.
           Read more:
           https://ponyfoo.com/articles/progressive-networking-serviceworker
        */
        var networked = fetch(event.request)
          // We handle the network request with success and failure scenarios.
          .then(fetchedFromNetwork, unableToResolve)
          // We should catch errors on the fetchedFromNetwork handler as well.
          .catch(unableToResolve);

        /* We return the cached response immediately if there is one, and fall
           back to waiting on the network as usual.
        */
        console.log('WORKER: fetch event', cached ? '(cached)' : '(network)', event.request.url);
        return cached || networked;

        function fetchedFromNetwork(response) {
          /* We copy the response before replying to the network request.
             This is the response that will be stored on the ServiceWorker cache.
          */
          var cacheCopy = response.clone();

          console.log('WORKER: fetch response from network.', event.request.url);

          caches
            // We open a cache to store the response for this request.
            .open(version + 'pages')
            .then(function add(cache) {
              /* We store the response for this request. It'll later become
                 available to caches.match(event.request) calls, when looking
                 for cached responses.
              */
              cache.put(event.request, cacheCopy);
            })
            .then(function() {
              console.log('WORKER: fetch response stored in cache.', event.request.url);
            });

          // Return the response so that the promise is settled in fulfillment.
          return response;
        }

        /* When this method is called, it means we were unable to produce a response
           from either the cache or the network. This is our opportunity to produce
           a meaningful response even when all else fails. It's the last chance, so
           you probably want to display a "Service Unavailable" view or a generic
           error response.
        */
        function unableToResolve () {
          /* There's a couple of things we can do here.
             - Test the Accept header and then return one of the `offlineFundamentals`
               e.g: `return caches.match('/some/cached/image.png')`
             - You should also consider the origin. It's easier to decide what
               "unavailable" means for requests against your origins than for requests
               against a third party, such as an ad provider
             - Generate a Response programmaticaly, as shown below, and return that
          */

          console.log('WORKER: fetch request failed in both cache and network.');

          /* Here we're creating a response programmatically. The first parameter is the
             response body, and the second one defines the options for the response.
          */
          return new Response('<h1>Service Unavailable</h1>', {
            status: 503,
            statusText: 'Service Unavailable',
            headers: new Headers({
              'Content-Type': 'text/html'
            })
          });
        }
      })
  );
});

Es gibt mehrere weitere Strategien, einige davon diskutiere ich in einem Artikel über ServiceWorker-Strategien auf meinem Blog.

Wie versprochen, werfen wir einen Blick auf den Code, mit dem Sie ältere Versionen Ihres ServiceWorker-Skripts ausmustern können.

Ausmustern älterer ServiceWorker-Versionen

Das activate-Ereignis wird ausgelöst, nachdem ein ServiceWorker erfolgreich installiert wurde. Es ist am nützlichsten, wenn eine ältere Version eines ServiceWorkers ausgemustert wird, da Sie zu diesem Zeitpunkt wissen, dass der neue Worker korrekt installiert wurde. In diesem Beispiel löschen wir alte Caches, die nicht mit der version des Workers übereinstimmen, den wir gerade installiert haben.

self.addEventListener("activate", function(event) {
  /* Just like with the install event, event.waitUntil blocks activate on a promise.
     Activation will fail unless the promise is fulfilled.
  */
  console.log('WORKER: activate event in progress.');

  event.waitUntil(
    caches
      /* This method returns a promise which will resolve to an array of available
         cache keys.
      */
      .keys()
      .then(function (keys) {
        // We return a promise that settles when all outdated caches are deleted.
        return Promise.all(
          keys
            .filter(function (key) {
              // Filter by keys that don't start with the latest version prefix.
              return !key.startsWith(version);
            })
            .map(function (key) {
              /* Return a promise that's fulfilled
                 when each outdated cache is deleted.
              */
              return caches.delete(key);
            })
        );
      })
      .then(function() {
        console.log('WORKER: activate completed.');
      })
  );
});

Zur Erinnerung: Es gibt ein Simple Offline Site Repository, das wir dafür erstellt haben. Sie können das Ganze als CodePen Project ansehen, und es ist sogar eine vollwertige Demo-Website.