PWA mit Angular

wer

Ich gehe hier davon aus, das eine Angular Anwendung die mit der Angular CLI aufgesetzt wurde besteht. Sonst muss das noch passieren.

ng new myPWAtutorial
cd myPWAtutorial
ng serve

Was ist eine Progressive Web App?

Der Begriff Progressive Web App bezieht sich auf eine Gruppe von Technologien, wie z. B. ServiceWorker und Push Benachrichtigungen, die native-ähnliche Leistung und Benutzerfreundlichkeit in Web-Anwendungen bringen können. Progressive Web Apps sind interessant, weil sie in gewisser Weise eine neue Technologie für das Web darstellen. Progressive Web-Anwendungen nutzen neue Technologien, um das Beste aus mobilen Websites und nativen Anwendungen für Benutzer zu bringen. Hier mal die Eigenschaften die eine PWA aufweisen sollte.

  • Progressiv
  • Auffindbar (verknüpfbar)
  • Erreichbar
  • Installierbar
  • Sichern
  • Konnektivität unabhängig

Aber was bedeuten diese?

Progressiv
Der Benutzer sieht eine Website, die durch das ansprechende Design perfekt an sein Gerät angepasst ist. Obwohl die Progressive Web App über eine URL abgerufen wird, kann der Benutzer ein Icon auf seinen Startbildschirm des Smartphones ziehen oder Push Benachrichtigungen erhalten und auch die Seite offline verwenden. Eine PWA nutzt die Hardware optimal aus und bietet dem Nutzer so ein perfektes Nutzungserlebnis.

Auffindbar
Da eine PWA eine Website ist, kann sie in Suchmaschinen gefunden werden. Dies ist ein großer Vorteil gegenüber nativen Anwendungen.

Erreichbar
Die PWA sollte in der Lage sein, Benachrichtigungen zu erhalten, auch wenn sie nicht aktiv verwendet wird.

Installierbar
Ein PWA kann auf dem Homescreen installiert werden, es handelt sich hierbei um einen einfachen shortcut. Dazu muss ein Banner angezeigt werden. Dies ermöglicht es dem Benutzer die PWA einfach zu starten – wie eine native App.

Sichern
Ein PWA kann den Netzwerkverkehr abfangen, also muss er durch HTTPS geschützt werden. Andernfalls werden Servicearbeiter vom Browser gesperrt.
Jede Website läuft in einer Sandbox namens Browser.

Konnektivität unabhängig
Ein PWA muss auch richtig starten und sinnvolle Inhalte anzeigen, wenn es keine oder schlechte Netzwerkverbindung gibt.

Genau genommen nutzt eine PWA keine einzelne Technologie, sondern ein ganzes Bündel an API’s.

  • ServiceWorker API
  • Cache API
  • Fetch API
  • Notification API
  • Push API
  • IndexedDB API

Das Herz einer PWA ist der ServiceWorker.

Aber was genau ist ServiceWorker?

Nun der ServiceWorker ist ein Prozess die im Browser läuft, im Scope der Website für die er registriert wurde.
Er kennt gewisse lifecycle die durch Events (Hooks) getriggert werden. Der ServiceWorker läuft aber nicht im Context deiner Website, sondern hat seinen eigenen Context.

Es gibt mehrere Wege um eine ServiceWorker zu bauen:

  1. Manuell, also komplett händisch schreiben
  2. Mittels eines von Google bereit gestellten Toolkit
  3. Angular ServiceWorker (ngSW)

Da ich hier eine Angular Anwendung erweitern möchte, nutze ich heute hier den Angular Weg, dafür installieren wir in unserem Angular Projekt ein neues Paket.

npm i -S @angular/service-worker

Das -S speichert das Paket in unserer package.json als dependency, wir wollen den ngSW auch in der Anwendung nutzen, mehr dazu später.

Um den ngSW in der Angular CLI zu verwenden, müssen wir der CLI nur noch sage, dass sie den ServiceWorker verwenden soll.
Dies tun wir indem wir einen Key setzen.

ng set apps.0.serviceWorker=true

Alternativ können wir den Key in der .angular-cli.json auch manuell setzen.

{...
 "apps": [
   {...
     "serviceWorker": true,...
   }
 ],
}

Der ngSW funtioniert nur im prod Mode

Also müssen wir unsere Anwendung bauen

ng build --prod -w

(-w sorgt dafür, dass automatisch neu gebaut wird, wenn sich dateien ändern) und den dist Ordner mit dem Webserver eurer Wahl bereitstellen. Ich verwende ein Chrome Plugin mit dem Namen Web Server for Chrome

Aber was passiert da jetzt genau??
Wenn ihr in den dist Ordner schaut, seht ihr u.a. drei neue Dateien die so vorher nicht da waren.

worker-basic.min.js   // Angular ServiceWorker
sw-register.<hash>.bundle.js   // Hier startet der ServciceWorker
ngsw-manifest.json   // ngSW Settings

Damit ist unsere Anwendung schon teilweise offline fähig. Alle generierten Dateien werden schon im cache gespeichert.

In den wenigsten Fällen reicht das aber, daher kann man den ngSW noch weiter Konfigurieren.
Hierfür erstellt man im Projekt Root (neben dem index.html) eine ngsw-manifest.json Datei und fügt diese dann in der .angular-cli.json in den assets hinzu. Diese ngsw-manifest.json wird dann zum build Zeitpunkt in die generelle Config gemerged.

Die ngSW stellt 5 plugins bereit die wir konfigurieren können.

  • static
  • routing
  • external
  • dynamic
  • push

static
Dieses Plugin ist für alle generierten Dateien zuständig und wird automatisch erstellt. Hier müssen wir nicht weiter tätig werden.

Wir können direkt in den Developer tools den offline modus aktivieren und unsere Anwendung kann trotzdem starten und findet die statischen dateien im cache.
Aber, wenn wir jetzt nicht die index.html im Browser öffnen, sondern eine Route die es in der Angular Anwendung gibt, sehen wir doch den Offline Dino oder ähnliches.
Der Grund dafür ist, das der ServiceWorker URL Scoped ist, also er funktioniert nur auf einer URL.
Ist ja eher unpraktisch wenn man Routing nutzt.
Zum Glück gibt es das Plugin routing.

Damit nun das RoutingModule von Angular weiter arbeiten kann und der ServiceWorker damit funtioniert muss man index und die routes angeben.

In die ngsw-manifest.json schreiben wir also z.B.:

{
  "routing": {
    "index": "/index.html",
    "routes": {
      "/": {
        "match": "exact"
      },
      "/books": {
        "match": "prefix"
      },
      "/about": {
        "match": "prefix"
      }
    }
  }
}

match kann follgende Werte haben:

  • exact (/books)
  • prefix (/books/*)
  • regex

Derzeit muss dieser Teil der Konfiguration händisch passieren, wahrscheinlich wird dieser Teil später zum build Zeitpunkt automatisch anhand der Route Configs in der Anwendung generiert.

Nun sollte ein reload auf einer x-beliebigen Route zum gewünschten Ergebnis (kein offline Dino) führen.

Aber es fehlen jetzt noch die externen Statischen Dateien, wenn z.B. ein Framework über ein cdn geladen werden.
Das lösen wir mit dem external Plugin.

Die ngsw-manifest.json erweitern wir also um einen external key.
Dieser Key bekommt als value ein Array von URLS.

{
 "routing": {...},
 "external": {
   "urls": [
     {
       "url": "external URL"
     },
     {
       "url": "external URL."
     }
   ]
 }

Fertig sind wir damit.


Nun ist unsere App soweit offline fähig, dass sie alle Statischen Dateien (lokale und externe) in den cache speichert und von dort bei reload läd.

Klar muss die Anwendung initial mindestens einmal online funktioniert haben, aber das versteht sich von selbst. 🙂

Was fehlt denn noch? Klar, wenn wir REST Abfragen machen und die Anwendung ist offline, dann laufen diese ins leere und liefern einen 404.
Daher wollen wir nun auch die Dynamischen Daten (z.B. Rest Calls) cachen.

 "dynamic": {
    "group": [
      {
        "name": "bookmonkey",
        "urls": {
          "http://localhost:4730/books": {
            "match": "prefix"
          }
        },
        "cache": {

        }
      }
    ]
  }

Dabei muss man sich einmal die Gefahren vor Augen halten:
Wenn ich dynamische Daten cache, kann es dazu kommen, dass diese nach dem ersten laden nie wieder aktualisiert werden, oder wenn ich alle calls cache, läuft irgendwann mein Speicher voll.
Beides wäre natürlich ungünstig.
Zum Glück können wir genau an diese Stellschrauben beim ngSW drehen.

 "dynamic": {
    "group": [
      {
        ...
        },
        "cache": {
          "optimizeFor": "freshness",
          "strategy": "lru",
          "maxAgeMs": 3600000,
          "maxEntries": 20,
          "networkTimeoutMs": 1000
        }
      }
    ]
  }

optimizeFor:
cache Strategie
performance (cache first)
freshness (network first)

strategy:
Was soll passieren, wenn der cache voll ist
fifo (der älteste Eintrag wird ersetzt)
lfu (der am häufigsten verwendet Eintrag wird ersetzt)
lru (der am seltenste verwendet Eintrag wird ersetzt)

maxAgeMs:
Wie lange sollen Daten im cache gehalten werden

maxEntries:
Wieviele Anfragen sollen gecached werden

networkTimeoutMs:
Wie lange soll auf Network Antwort gewartet werden bis der cache als fallback abgefragt wird


Alles was wir bisher umgesetzt haben, kann so auch mit anderen PWA tools wie dem oben bereits erwähnten Google Toolkit umgesetzt werden.

Der ngSW bietet aber noch ein spezielles Module, welches in unserer Anwendung genutzt werden kann um aus der Anwendung mit dem ngSW zu kommunizieren, das ServiceWorkerModule.

Hiermit können wir z.B. das verhalten steuern wenn ein neuer ServiceWorker verfügbar ist.

Ok, gehen wir das ganze mal zusammen durch.

ServiceWorkerModule installieren:
Zuerst importieren wir das ServiceWorkerModule in unserem AppModule (app.module.ts).


import { ServiceWorkerModule } from '@angular/service-worker';

@NgModule({
 declarations: [
   ...
 ],
 imports: [
   ...
   ServiceWorkerModule
 ]
 ...
})
export class AppModule { }

Danach abonieren wir ServiceWorker updates und fragen den User bei einem verfügbaren Update ob dieses installiert werden soll (app.module.ts).

...
export class AppModule {
  constructor(sw: NgServiceWorker) {

    // listen for updates
    sw.updates.subscribe(event => {
      console.log('-->', event)
      if (event.type === 'pending') {
        if (window.confirm('There is a new version available. Do you want to update?')) {
          sw.activateUpdate(event.version);
        }
      } else {
        location.reload();
      }
    })
  }
 }


Das nächste Feature welches wir umsetzen wollen, ist ein sehr mächtiges, daher bitte ich euch damit auch verantwortungsvoll umzugehen.

Aus großer Kraft folgt große Verantwortung. – Stan Lee

Die Rede ist von Push Benachrichtigungen.

Dabei kann sich ein User Push benachrichtigungen abonieren und diese werden dann von der Website und unter Zuhilfenahme kleiner externer Dienste, an den Browser gesendet.

Dieses Feature ist zur Zeit in Chrome, Mozilla und Opera verfügbar.
Im Edge wird es ab dem windows anniversary update verfügbar sein.
Im Safari wird es wohl erstmal nicht verfügbar sein, aber zur Zeit ist da ganz viel Bewegung drin, daher kann sich das sehr schnell ändern. Wir hoffen das Beste.

Ok wie läuft das jetzt genau ab, mit den Push Benachrichtigungen?
Um Push nutzen zu können benötige ich zuerst mal ein VAPID (Voluntary Application Server Identification).
Diese neue Spezifikation definiert im Wesentlichen einen Handshake zwischen dem App-Server und dem Push-Service, so dass der Push-Service überprüfen kann, welche Website Nachrichten sendet.
Ihr bekommt das nötige Schlüsselpaar z.b. hier.

Der Abonier vorgang sieht dann wie follgt aus.
Über die Push API sendet amn einen Subscripe request an den Message Server, dieser ist Browser spezifisch.
Von diesem bekommt man dann ein VAPID welches man auf einem Push Server speichert welcher Teil der Anwendung ist, ich habe hier ein gist angelegt.
Dieser Server kann nun über das VAPID Push Benachrichtigungen versenden (naja, eher triggern).
Der Message Server empfängt diesen Push trigger mit Informationen die in der Push Benachrichtigung angezeigt werden sollen und sendet die Push Benachrichtigung an den Browser, die Informationen bekommt er aus dem VAPID.

Ok, soweit die Theorie.

Um das ganze nun umzusetzen müssen wir erstmal Push im ngSW aktivieren.

ngsw-manifest.json:

{
 "routing": {...},
 "external": {...},
 "dynamic": {...},
 "push": {
   "showNotifications": true,
   "backgroundOnly": false
 }
}

Anschließend generieren wir mit der CLI einen PushService und eine PushComponent:

ng g s shared/push
ng g c push

Mittels des PushService wollen wir uns am Server für Push registrieren und Deregistrieren können und natürlich wollen wir auch Push Benachrichtigungen senden .

push.service.ts:

import { Http } from '@angular/http';
import { Injectable } from '@angular/core';
import { NgPushRegistration } from '@angular/service-worker';

@Injectable()
export class PushService {
    private subscription: NgPushRegistration;

    constructor(private http: Http) { }
 

    subscribeToPush(subscr: NgPushRegistration) {
    this.subscription = subscr;
    this.http.post(
        'http://localhost:3030/webpush',
        { action: 'subscribe', subscription: subscr }
    )
        .map(res => res.json()).subscribe(data => console.log('--->', data))
    }

    unsubscribeFromPush() {
    this.http.post(
        'http://localhost:3030/webpush',
        { action: 'unsubscribe', subscription: this.subscription }
    )
        .map(res => res.json()).subscribe(data => {
        console.log('--->', data);
        this.subscription = null;
        })
    }
    sendPush(msg) {
    const payload = { 'users': ['ALL'], 'msg': { 'msg': msg } };
    return this.http.post( 'http://localhost:3030/msg', payload )
        .map(res => res.json())
    }

}

Um den Service auch aufrufen zu können, habe ich die PushComponnet eingebaut.

push.component.ts:

import { NgServiceWorker, NgPushRegistration } from '@angular/service-worker';

@Component({
 selector: 'push',
 templateUrl: './push.component.html',
 styleUrls: ['./push.component.scss']
})
export class PushComponent implements OnInit {
    pubKey = '...';
    msg = { title: '', message: '' };

    constructor(private sw: NgServiceWorker, private push: PushService) { }
    subscribeToPush() {
    // Subscribe to Push Notifications
    this.sw.registerForPush({ applicationServerKey: this.pubKey })
        .subscribe((r: NgPushRegistration) => {
        console.log('successfully registered', r);
        this.push.subscribeToPush(r)
        },
        err => {
        console.error('error registering for push', err);
        });
    }
    unsubscribeFromPush() {
    this.push.unsubscribeFromPush();
    }

    sendMessage(msg) {
    this.push.sendPush(msg).subscribe(data => {
        msg = { title: '', message: '' };
    })
    }

}

Hier das Template dazu.

<p>
  push works!
</p>
<button (click)="subscribeToPush()">Subscribe to Push</button>
<button (click)="unsubscribeFromPush()">Unsubscribe from Push</button>
<hr>
<form #form="ngForm" class="form-horizontal"  (ngSubmit)="sendMessage(form.value)">
  <div class="form-group">
    <label for="title" class="col-sm-2 control-label">Title</label>
    <div class="col-sm-10">
      <input type="test" class="form-control" id="title" name="title" required minlength="3" placeholder="Title"  [(ngModel)]="msg.title" >
    </div>
  </div>
  <div class="form-group">
    <label for="message" class="col-sm-2 control-label">Message</label>
    <div class="col-sm-10">
      <input type="test" class="form-control" id="message" name="message" required minlength="5" placeholder="Message"  [(ngModel)]="msg.message" >
    </div>
  </div>
  <div class="form-group">
    <div class="col-sm-offset-2 col-sm-10">
      <button type="submit" [disabled]="form.invalid" class="btn btn-default">Send</button>
    </div>
  </div>
</form>

Nun solltet ihr eine Angular Anwendung haben die Offline funktioniert und Push Benachrichtigungen empfangen kann.

Um die PWA nun noch auf dem Homescreen installierbar zu machen, müssen wir nur eine manifest.json neben die index.html legen und diese darin refferenzieren. Der Browser bietet denm User dann an die App auf dem Homescreen abzulegen.

manifest.json:

{
   "dir": "ltr",
   "lang": "en",
   "name": "PWA Workshop",
   "scope": "/",
   "display": "standalone",
   "start_url": "./?utm_source=web_app_manifest",
   "short_name": "PWAWorkshop",
   "theme_color": "#c34c91",
   "description": "",
   "orientation": "any",
   "background_color": "#3a1c8d",
   "related_applications": [],
   "prefer_related_applications": false
}

.angular-cli.json:

...
 "assets": [
        ...,
        "manifest.json"
      ],

index.html:

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

Wenn ihr dazu noch fragen habt, pingt mich gerne in unserem Slack Channel an.
angularjs-de.slack.com

Lasst auch gerne ein Kommentar hier.

Gerne könnt ihr euch auch zu unserem Angular Workshops anmelden, wo wir das und andere Themen noch vertiefen werden.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *