Moin moin und hallo,

in folgendem Artikel möchte ich zeigen was Progressive Web Apps sind und wie man diese in eine bestehende Seite integriert.


Was sind Progressive Web Apps?

Unter Progressive Web Apps versteht man Web-Anwendungen, die sowohl auf Desktop als auch auch auf mobilen Geräten laufen, und dass auch wenn keine Verbindung zum Internet vorliegt.

Um “progressive” zu sein reicht es daher “mobile-first” (Responsive Design) nicht mehr, man muss auch “offline-first” sein.

Der nächste Schritt, der voraussichtlich dannach auf uns zu kommt ist der Zugriff auf native Funktionen von mobilen Geräten, wie z.B. Bleutooth.

Dafür gibt es bereits eine Spezifikation und erste Lösungen, aber noch keinen großen Support seitens der Browser.

Wie geht offline-first?

Um eine bestehende Web-Anwendung offline-fähig zu machen muss man derzeit auf so genannte “Service-Worker” zu greifen und mit Ihnen einen eigenen Cache steuern.

Die API dafür haben viele großen Browser (Chrome, FF, Opera) bereits implementiert, oder es befindet sich (wie im Fall von IE Edge) in der Entwicklung.

Lediglich das Team um den Safari weigert sich noch (Plan ist erst in den nächsten 5 Jahren etwas vielleicht zur Verfügung zur stellen), weil die Technologie, wie ich zur Zeit mutmaße, einen direkten Angriff auf die iOS App-Stores darstellen könnte.

Man kann nämlich Progressive Web Apps sich unmittelbar auf den Home-Screen legen und weil diese offline-fähig sind auch dann nutzen, wenn keine Verbindung zum Internet vorliegt.

Die Entwicklung nativer Apps (Android und iOS) wird damit obsolet.

Die Downloadzahlen sind bei vielen größeren Apps dieses Jahr ohnehin stark rückläufig.

Wie erkennt man ob der Browser Service Worker unterstützt?

Ein großer Vorteil von der Service Worker Lösung, ist dass wenn ein Gerät die API nicht unterstütz, dieses weiterhin die Seite nutzen kann, nur eben nicht mit der Offline-Fähigkeit.

Ob ein Browser Service Worker untersützt erfährt man über das “navigator” objekt:

  // kennt der browser das Service worker objekt?
  if (navigator.serviceWorker) {

      // registriere das Service Worker script
      navigator.serviceWorker.register('/sw.js').then(function (reg) {

         // breche ab wenn der service worker controller nicht vorhanden ist, 
         //d.h. unvollständig implementiert
          if (!navigator.serviceWorker.controller) {
              return;
          }

          // gibt es eine neue service worker version?
          if (reg.waiting) {

              //warte nicht auf aktivierung, sondenr aktiviere das skript sofort
              reg.waiting.postMessage({
                  action: 'skipWaiting'
              });
              return;
          }
      })
  }

  // neue worker version wurde installiert, deshalb lade aktuelle maske einmal neu
  navigator.serviceWorker.addEventListener('controllerchange', function () {
      window.location.reload();
  });

Das Script zur Registrierugn eines neuen workers, wird wie gewohnt am Ende des “body” elements eingefügt. Es gibt keine externe Abhängikeit, d.h. der Brwoser sollte alle notwendingen Funktionen, die wir nutzen möchten, von sich aus mitbringen.

Wie nutzt man den Sevrice Worker um eine Seite zu cachen?

Dazu muss man erstmal evrstehen, dass der Service worker vor jedem Request/Response auf gerufen wird, bevor der Browser diesen verarbeitet.

Das erlaubt uns nämlich erst diese Anfragen bzw. Anworten zu cachen.

Das *sw.js Skript sieht dabei wie folgt aus:

// cache name für unseren content anfragen
var staticCacheName = 'software-kraut-static-v2';

// cache name für bilder
var contentImgsCache = 'software-kraut-content-imgs';

// alle unsere caches
var allCaches = [
  staticCacheName,
  contentImgsCache
];

//wird aufgerufen wenn der worker installiert wird
self.addEventListener('install', function (event) {

    // wir warten bis alle caches angelegt worden sind
    event.waitUntil(

        //registriere zu cachende Ressourcen für unseren content cache
        caches.open(staticCacheName).then(function (cache) {
            return cache.addAll([
        '/',
        'bootstrap-3.3.6-dist/js/boot.js',
        'styles/all.css',
        'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.6.0/highlight.min.js',
        'https://fonts.gstatic.com/s/roboto/v15/2UX7WLTfW3W8TclTUvlFyQ.woff',
        'https://fonts.gstatic.com/s/roboto/v15/d-6IYplOFocCacKzxwXSOD8E0i7KZn-EPnyo3HZu7kw.woff'
      ]);
        })
    );
});


// nachdem der cache installeirt wurde, wird dieser aktiviert
self.addEventListener('activate', function (event) {

    //warte bis alle anderen caches, die wir nicht mehr brauchen entfernt worden sind
    event.waitUntil(

        caches.keys().then(function (cacheNames) {
            return Promise.all(
                cacheNames.filter(function (cacheName) {

                    //suche nur unsere caches raus
                    return cacheName.startsWith('software-kraut-') 
                    && !allCaches.includes(cacheName);

                }).map(function (cacheName) {

                    //lösche den alten cache
                    return caches.delete(cacheName);
                })
            );
        })
    );
});


// fange alle browser anfragen ab
self.addEventListener('fetch', function (event) {

    var requestUrl = new URL(event.request.url);

    // sind wir im root unserer Seite, dann antworte mit der gecachten version
    if (requestUrl.origin === location.origin) {
        if (requestUrl.pathname === '/') {
            event.respondWith(caches.match('/'));
            return;
        }

        //wenn irgendein Bild angefragt wird (aus dem /images Verzeichnis)
        //antworte mit gecachter version zuerst, ansonsten lade und cache die Ressource
        if (requestUrl.pathname.startsWith('/images/')) {
            event.respondWith(serveImages(event.request));
            return;
        }

        //wenn ein Artikel angefragt wird (aus dem /articles Verzeichnis)
        //antworte mit gecachter Version zuerst, ansonsten lade und cache den Artikel
        if (requestUrl.pathname.startsWith('/articles/')) {
            event.respondWith(serveArticle(event.request));
            return;
        }
    }

    //ansonsten versuche mit einer gecachten Version zu anworten oder lade 
    //wie gewohnt die Ressource
    event.respondWith(
        caches.match(event.request).then(function (response) {
            return response || fetch(event.request);
        })
    );
});


function serveImages(request) {

   //bilder können in verschiedenen auflösungen vorliegen, gecacht werden soll 
   //aber nur unter dem allgemeinen namen
    var storageUrl = request.url.replace(/-\dx\.jpg$/, '');

    //öffne den bilder cache 
    return caches.open(contentImgsCache).then(function (cache) {

       //versuche das bild im cache zu finden
        return cache.match(storageUrl).then(function (response) {

            // baue promise auf, für den fall dass das Bild nicht im Cache vorliegt
            var netResponse = fetch(request).then(function (networkResponse) {

                //WICHTIG: **clocne()** sorgt dafür dass wir den Response nicht schon 
                //jetzt verarbeiten und damit die weitere Anwendungslogik stören.
                //das auslesend es Response-Body geht nämlich immer nur genau einmal!
                //wir cachen also den Clone einer Anwort zu der Anfrage
                cache.put(storageUrl, networkResponse.clone());

                return networkResponse;
            });

            //versuche mit dem gecachten Response zu antworten, ansonsten nimm den Netwerk Promise
            return response || netResponse;
        });
    });
}


//analog zu serveImages(), nur dass nicht aus dem Bild-Cache sondern content-cache 
//gelesen / geschrieben wird.
function serveArticle(request) {

    var storageUrl = request.url;

    return caches.open(staticCacheName).then(function (cache) {
        return cache.match(storageUrl).then(function (response) {

            var netResponse = fetch(request).then(function (networkResponse) {
                cache.put(storageUrl, networkResponse.clone());

                return networkResponse;
            });

            return response || netResponse;
        });
    });
}


//ein service worker kann auch auf "message" events reagiert, sodass wir eine eigene 
//logik umsetzen können
self.addEventListener('message', function (event) {

    //wenn wir den worker zwingen möchtne sich zu isntalleiren, dann sagen wir 
    //ihm das hiermit
    if (event.data.action === 'skipWaiting') {
        self.skipWaiting();
    }
});

Im Grunde ist es das auch schon. Der Code umfasst vielleciht 100 Zeilen und cached bereits jetzt diesen Blog, den Sie gerade lesen. Sie könnten also jetzt den Stecker am Router ziehen und sollten weiterhin den Blog lesen können.

Natürlich ist es nicht sehr ratsam sämtliche Ressourcen und Unterseiten vorab zu cachen. Deshalb cache ich auch nur wirklich das Minimum wie die CSS, JS Abhängikeiten und die Startseite.

Alle folgenden Artikel werden erst dann gecached, wenn der Nutzer die jeweilige Seite auf macht.

Es ist aber darauf zu achten, dass wenn man einen neuen Artikel oder anderen Inhalt der Seite hinzufügt, die Cache-Version hoch zu schrauben, weil ansonsten der Nutzer nur die alten Versionen aus seinem Cache zu sehen bekommt.

Das Manifest

Damit der mobile Nutzer unser Webseite als App erkennt müssen wir noch eine “manifest.json” deklarieren, die Informationen zu verschiedenen Icons, Startseite, Ausrichtung, usw. enthält.

Die minimale KOnfiguration sieht dabei wie folgt:


{
    "short_name": "Software-Kraut",
    "name": "Software-Kraut",
    "icons": [
        {
            "src": "/images/icon/icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
  ],
    "display": "standalone",
    "start_url": "/index.html",
    "orientation": "portrait"
}

Das Manifest muss dem Browser aber noch über ein Link mitgeteilt werden.

Dazu muss man im “head” der Seite folgenden “link” eintragen:

<link rel="manifest" href="/manifest.json">

Das Manifest kann aber noch viel mehr Steueranweisungen für den Browser aufnehmen, die man hier alle nachschlagen kann.

Wichtig

Eine Progressive Web App kann erst dann auf dem Home-Screen angelegt werden, wenn diese üebr eine **https**-Verbindung funktioniert. Ohne ein SSL-zertifikat funktioniert die App auch weitehrin offline, nur erlaubt z.B. ein Android-OS nicht die Anlage auf dem Home-Screen.

Möchten Sie also meinen Blog als eine Progressive Web App bei sich lokal mal installieren müssen Sie folgende URL aufrufen: https://software-kraut.herokuapp.com/


#### In diesem Sinne, bis demnächst!