Progressive Web-App für die Google I/O 2016 entwickeln

Iowa

Zusammenfassung

Hier erfahren Sie, wie wir mit Webkomponenten, Polymer und Material Design eine Single-Page-App erstellt und auf Google.com eingeführt haben.

Ergebnisse

  • Mehr Interaktionen als in der nativen App (4:06 Minuten im mobilen Web im Vergleich zu 2:40 Minuten bei Android).
  • 450 ms schnellere First Paint für wiederkehrende Nutzer dank Service Worker-Caching
  • 84% der Besucher unterstützten Service Worker
  • Die Anzahl der Elemente, die Nutzer mit der Funktion „Zum Startbildschirm hinzufügen“ gespeichert haben, ist im Vergleich zu 2015 um 900% gestiegen.
  • 3,8% der Nutzer waren offline, generierten aber trotzdem 11.000 Seitenaufrufe.
  • 50% der angemeldeten Nutzer haben Benachrichtigungen aktiviert.
  • 536.000 Benachrichtigungen wurden an Nutzer gesendet (12% gaben sie zurück).
  • Die Polyfills für Webkomponenten wurden von 99% der Browser der Nutzer unterstützt.

Übersicht

Dieses Jahr hatte ich das Vergnügen, an der progressiven Webanwendung für die Google I/O 2016 zu arbeiten, die liebevoll „IOWA“ genannt wird. Es ist mobilfreundlich, funktioniert vollständig offline und wurde stark vom Material Design inspiriert.

IOWA ist eine Single-Page-Anwendung (SPA), die mit Webkomponenten, Polymer und Firebase erstellt wurde. Sie hat ein umfangreiches Backend, das in der App Engine (Go) geschrieben wurde. Es speichert Inhalte mithilfe eines Dienst-Workers im Cache, lädt neue Seiten dynamisch, wechselt nahtlos zwischen Ansichten und verwendet Inhalte nach dem ersten Laden wieder.

In dieser Fallstudie gehe ich auf einige der interessanteren architektonischen Entscheidungen ein, die wir für das Frontend getroffen haben. Den Quellcode finden Sie auf GitHub.

Auf GitHub ansehen

SPA mit Webkomponenten erstellen

Jede Seite als Komponente

Einer der Hauptaspekte unseres Frontends ist, dass es sich um Webkomponenten dreht. Tatsächlich ist jede Seite in unserer SPA eine Webanwendung:

    <io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
    <io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
    <io-attend-page></io-attend-page>
    <io-extended-page></io-extended-page>
    <io-faq-page></io-faq-page>

Warum haben wir das getan? Der erste Grund ist, dass dieser Code lesbar ist. Für einen Erstleser ist es völlig offensichtlich, was jede Seite in unserer App ist. Der zweite Grund ist, dass Webkomponenten einige nützliche Eigenschaften für die Erstellung einer SPA haben. Viele häufige Probleme (Statusverwaltung, Ansichtsaktivierung, Stilbereich) werden durch die inhärenten Funktionen des <template>-Elements, benutzerdefinierter Elemente und des Shadow DOM behoben. Dies sind Entwicklertools, die in den Browser eingebunden sind. Warum sollten Sie sie nicht nutzen?

Durch das Erstellen eines benutzerdefinierten Elements für jede Seite haben wir viele Vorteile erhalten:

  • Verwaltung des Seitenlebenszyklus
  • CSS/HTML-Code, der speziell für die Seite gilt.
  • Alle für eine Seite spezifischen CSS-/HTML-/JS-Dateien werden gebündelt und bei Bedarf zusammen geladen.
  • Ansichten können wiederverwendet werden. Da Seiten DOM-Knoten sind, ändert sich die Ansicht, wenn Sie sie hinzufügen oder entfernen.
  • Künftige Entwickler können unsere App einfach durch das Grok-Markup verstehen.
  • Serverseitig gerendertes Markup kann nach und nach verbessert werden, wenn Elementdefinitionen vom Browser registriert und aktualisiert werden.
  • Benutzerdefinierte Elemente haben ein Überschreibungsmodell. DRY-Code ist guter Code.
  • …und vieles mehr.

Wir haben diese Vorteile in IOWA voll ausgeschöpft. Sehen wir uns einige Details an.

Seiten dynamisch aktivieren

Das Element <template> ist die Standardmethode des Browsers zum Erstellen wiederverwendbaren Markups. <template> hat zwei Eigenschaften, die SPAs nutzen können. Zuerst ist alles innerhalb der <template> inaktiv, bis eine Instanz der Vorlage erstellt wird. Zweitens: Der Browser parset das Markup, die Inhalte sind aber nicht über die Hauptseite erreichbar. Es ist ein echter, wiederverwendbarer Markup-Abschnitt. Beispiel:

<template id="t">
    <div>This markup is inert and not part of the main page's DOM.</div>
    <img src="profile.png"> <!-- not loaded by the browser -->
    <video id="vid" src="vid.mp4"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer erweitert die <template> um einige benutzerdefinierte Elemente zur Typerweiterung, nämlich <template is="dom-if"> und <template is="dom-repeat">. Beide sind benutzerdefinierte Elemente, die <template> um zusätzliche Funktionen erweitern. Dank der deklarativen Natur von Webkomponenten funktionieren beide genau so, wie Sie es erwarten. Die erste Komponente prägt das Markup basierend auf einer Bedingung. Bei der zweiten wird das Markup für jedes Element in einer Liste (Datenmodell) wiederholt.

Wie verwendet IOWA diese Elemente zur Typerweiterung?

Wie Sie sich erinnern, ist jede Seite in IOWA eine Webkomponente. Es wäre jedoch unsinnig, jede Komponente beim ersten Laden zu deklarieren. Das würde bedeuten, dass beim ersten Laden der App eine Instanz jeder Seite erstellt werden müsste. Wir wollten die Leistung beim ersten Laden nicht beeinträchtigen, da einige Nutzer nur eine oder zwei Seiten aufrufen.

Unsere Lösung war es, zu betrügen. In IOWA wickeln wir jedes Seitenelement in ein <template is="dom-if"> ein, damit der Inhalt beim ersten Start nicht geladen wird. Anschließend aktivieren wir Seiten, wenn das name-Attribut der Vorlage mit der URL übereinstimmt. Die Webkomponente <lazy-pages> übernimmt diese gesamte Logik für uns. Das Markup sieht in etwa so aus:

<!-- Lazy pages manages the template stamping. It watches for route changes
        and sets `template.if = true` on the appropriate template. -->
<lazy-pages>
    <template is="dom-if" name="home">
    <io-home-page date="2016-05-18T17:00:00Z"></io-home-page>
    </template>

    <template is="dom-if" name="schedule">
    <io-schedule-page date="2016-05-18T17:00:00Z"></io-schedule-page>
    </template>

    <template is="dom-if" name="attend">
    <io-attend-page></io-attend-page>
    </template>
</lazy-pages>

Mir gefällt daran, dass jede Seite beim Laden geparst und einsatzbereit ist, aber ihr CSS/HTML/JS nur bei Bedarf ausgeführt wird (wenn das übergeordnete <template> gestempelt wird). Dynamische und Lazy-Ansichten mit Webkomponenten sind einfach super.

Künftige Verbesserungen

Beim ersten Laden der Seite werden alle HTML-Importe für jede Seite gleichzeitig geladen. Eine offensichtliche Verbesserung wäre es, die Elementdefinitionen nur dann zu laden, wenn sie benötigt werden. Polymer bietet auch eine praktische Funktion für das asynchrone Laden von HTML-Importen:

Polymer.Base.importHref('io-home-page.html', (e) => { ... });

IOWA tut dies nicht, weil a) wir faul geworden sind und b) es unklar ist, wie groß der Leistungsanstieg gewesen wäre. Unsere erste Paint-Zeit betrug bereits etwa 1 Sekunde.

Verwaltung des Seitenlebenszyklus

Die Custom Elements API definiert Lebenszyklusereignisse zum Verwalten des Status einer Komponente. Wenn Sie diese Methoden implementieren, erhalten Sie Zugriff auf die Lebensdauer einer Komponente:

createdCallback() {
    // automatically called when an instance of the element is created.
}

attachedCallback() {
    // automatically called when the element is attached to the DOM.
}

detachedCallback() {
    // automatically called when the element is removed from the DOM.
}

attributeChangedCallback() {
    // automatically called when an HTML attribute changes.
}

Die Callbacks in IOWA waren einfach zu nutzen. Denken Sie daran, dass jede Seite ein eigenständiger DOM-Knoten ist. Um zu einer „neuen Ansicht“ in unserer SPA zu wechseln, müssen wir einen Knoten an das DOM anhängen und einen anderen entfernen.

Wir haben die attachedCallback verwendet, um die Einrichtung durchzuführen (Zustand initialisieren, Ereignis-Listener anhängen). Wenn Nutzer zu einer anderen Seite wechseln, führt detachedCallback eine Bereinigung durch (entfernt Listener, setzt den geteilten Status zurück). Außerdem haben wir die nativen Lebenszyklus-Callbacks um einige eigene erweitert:

onPageTransitionDone() {
    // page transition animations are complete.
},

onSubpageTransitionDone() {
    // sub nav/tab page transitions are complete.
}

Diese waren nützliche Ergänzungen, um die Arbeit zu verzögern und Ruckler zwischen den Seitenübergängen zu minimieren. Mehr dazu später.

Gemeinsame Funktionen auf allen Seiten vereinfachen

Die Vererbung ist eine leistungsstarke Funktion von benutzerdefinierten Elementen. Es bietet ein standardmäßiges Vererbungsmodell für das Web.

Leider ist die Elementübernahme in Polymer 1.0 noch nicht implementiert. In der Zwischenzeit war die Behaviors-Funktion von Polymer genauso nützlich. Verhaltensweisen sind nur Mixins.

Anstatt auf allen Seiten dieselbe API-Oberfläche zu erstellen, war es sinnvoll, die Codebasis durch gemeinsame Mixins zu optimieren. In PageBehavior werden beispielsweise häufig verwendete Eigenschaften/Methoden definiert, die alle Seiten in unserer App benötigen:

PageBehavior.html

let PageBehavior = {

    // Common properties all pages need.
    properties: {
    name: { type: String }, // Slug name of the page.
    ...
    },

    attached() {
    // If the page defines a `onPageTransitionDone`, call it when the router
    // fires 'page-transition-done'.
    if (this.onPageTransitionDone) {
        this.listen(document.body, 'page-transition-done', 'onPageTransitionDone');
    }

    // Update page meta data when new page is navigated to.
    document.body.id = `page-${this.name}`;
    document.title = this.title || 'Google I/O 2016';

    // Scroll to top of new page.
    if (IOWA.Elements.Scroller) {
        IOWA.Elements.Scroller.scrollTop = 0;
    }

    this.setupSubnavEffects();
    },

    detached() {
    this.unlisten(document.body, 'page-transition-done', 'onPageTransitionDone');
    this.teardownSubnavEffects();
    }
};

IOWA.IOBehaviors = IOWA.IOBehaviors || {PageBehavior: PageBehavior};

Wie Sie sehen, führt PageBehavior gängige Aufgaben aus, die ausgeführt werden, wenn eine neue Seite aufgerufen wird. Dazu gehören beispielsweise das Aktualisieren der document.title, das Zurücksetzen der Scrollposition und das Einrichten von Ereignis-Listenern für Scroll- und Navigationseffekte.

Auf einzelnen Seiten wird PageBehavior verwendet, indem es als Abhängigkeit geladen und behaviors verwendet wird. Bei Bedarf können sie auch die Basiseigenschaften/-methoden überschreiben. Hier ein Beispiel für die Überschreibungen der „subclass“ unserer Startseite:

io-home-page.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="PageBehavior.html">
<!-- rest of the import dependencies used by the page. -->

<dom-module id="io-home-page">
    <template>
    <!-- PAGE'S MARKUP -->
    </template>
    <script>
    Polymer({
        is: 'io-home-page',

        behaviors: [IOBehaviors.PageBehavior], // All pages have common functionality.

        // Pages define their own title and slug for the router.
        title: 'Schedule - Google I/O 2016',
        name: 'home',

        // The home page has custom setup work when it's added navigated to.
        // Note: PageBehavior's attached also gets called.
        attached() {
        if (this.app.isPhoneSize) {
            this.listen(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
        }
        },

        // The home page does its own cleanup when a new page is navigated to.
        // Note: PageBehavior's detached also gets called.
        detached() {
        this.unlisten(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
        },

        // The home page can define onPageTransitionDone to do extra work
        // when page transitions are done, and thus preventing janky animations.
        onPageTransitionDone() {
        ...
        }
    });
    </script>
</dom-module>

Stile freigeben

Um Stile für verschiedene Komponenten in unserer App zu teilen, haben wir die gemeinsamen Stilmodule von Polymer verwendet. Mit Stilmodulen können Sie einen CSS-Codeblock einmal definieren und an verschiedenen Stellen in einer App wiederverwenden. Für uns bedeutete „verschiedene Stellen“ verschiedene Komponenten.

In IOWA haben wir shared-app-styles erstellt, um Farben, Typografie und Layoutklassen für Seiten und andere von uns erstellte Komponenten zu teilen.

shared-app-styles.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../bower_components/paper-styles/color.html">

<dom-module id="shared-app-styles">
    <template>
    <style>
        [layout] {
        @apply(--layout);
        }
        [layout][horizontal] {
        @apply(--layout-horizontal);
        }
        .scrollable {
        @apply(--layout-scroll);
        }
        .noscroll {
        overflow: hidden;
        }
        /* Style radio buttons and tabs the same throughout the app */
        paper-tabs {
        --paper-tabs-selection-bar-color: currentcolor;
        }
        paper-radio-button {
        --paper-radio-button-checked-color: var(--paper-cyan-600);
        --paper-radio-button-checked-ink-color: var(--paper-cyan-600);
        }
        ...
    </style>
    </template>
</dom-module>

io-home-page.html

<link rel="import" href="shared-app-styles.html">
<!-- Rest of import dependencies used by the page. -->

<dom-module id="io-home-page">
    <template>
    <style include="shared-app-styles">
        :host { display: block} /* Other element styles can go here. */
    </style>
    <!-- PAGE'S MARKUP -->
    </template>
    <script>Polymer({...});</script>
</dom-module>

Hier bedeutet <style include="shared-app-styles"></style> in der Polymer-Syntax „Fügen Sie die Stile in das Modul mit dem Namen „shared-app-styles“ ein“.

Anwendungsstatus teilen

Sie wissen inzwischen, dass jede Seite in unserer App ein benutzerdefiniertes Element ist. Ich habe es schon tausend Mal gesagt. Ok, aber wenn jede Seite eine eigenständige Webkomponente ist, fragen Sie sich vielleicht, wie wir den Status in der App teilen.

IOWA verwendet eine Methode, die der Abhängigkeitsinjektion (Angular) oder Redux (React) ähnelt, um den Status zu teilen. Wir haben eine globale app-Property erstellt und freigegebene untergeordnete Properties daran angehängt. app wird in unserer Anwendung übergeben, indem es in jede Komponente eingefügt wird, die seine Daten benötigt. Mit den Datenbindungsfunktionen von Polymer ist das ganz einfach, da wir die Verkabelung ohne Code schreiben können:

<lazy-pages>
    <template is="dom-if" name="home">
    <io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
    </template>

    <template is="dom-if" name="schedule">
    <io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
    </template>
    ...
</lazy-pages>

<google-signin client-id="..." scopes="profile email"
                            user="{ % templatetag openvariable % }app.currentUser}}"></google-signin>

<iron-media-query query="(min-width:320px) and (max-width:768px)"
                                query-matches="{ % templatetag openvariable % }app.isPhoneSize}}"></iron-media-query>

Das Element <google-signin> aktualisiert seine Property user, wenn sich Nutzer in unserer App anmelden. Da diese Property an app.currentUser gebunden ist, muss jede Seite, die auf den aktuellen Nutzer zugreifen möchte, einfach an app gebunden werden und die untergeordnete Property currentUser lesen. Diese Technik ist für sich genommen nützlich, um den Status in der gesamten App zu teilen. Ein weiterer Vorteil war jedoch, dass wir ein Element für die einmalige Anmeldung erstellt und die Ergebnisse auf der gesamten Website wiederverwendet haben. Dasselbe gilt für die Medienabfragen. Es wäre verschwenderisch gewesen, die Anmeldung für jede Seite zu duplizieren oder eigene Mediaabfragen zu erstellen. Stattdessen befinden sich Komponenten, die für app-weite Funktionen/Daten verantwortlich sind, auf App-Ebene.

Seitenübergänge

Wenn Sie sich in der Google I/O-Web-App bewegen, werden Sie die fließenden Seitenübergänge (à la Material Design) bemerken.

Die Seitenübergänge von IOWA in Aktion.
Die Seitenübergänge von IOWA in Aktion.

Wenn Nutzer eine neue Seite aufrufen, geschieht Folgendes:

  1. In der oberen Navigationsleiste wird eine Auswahlleiste zum neuen Link verschoben.
  2. Die Überschrift der Seite wird ausgeblendet.
  3. Der Inhalt der Seite wird nach unten geschoben und dann ausgeblendet.
  4. Wenn Sie diese Animationen rückwärts abspielen, werden die Überschrift und der Inhalt der neuen Seite angezeigt.
  5. Optional: Auf der neuen Seite wird eine zusätzliche Initialisierung durchgeführt.

Eine der Herausforderungen bestand darin, herauszufinden, wie wir diesen flüssigen Übergang gestalten können, ohne die Leistung zu beeinträchtigen. Es gibt viel dynamische Arbeit und Lagereien waren bei unserer Party nicht willkommen. Unsere Lösung bestand aus einer Kombination aus der Web Animations API und Promises. Die Kombination der beiden Tools bot uns Flexibilität, ein Plug-and-Play-Animationssystem und eine detaillierte Steuerung, um das Ruckeln zu minimieren.

Funktionsweise

Wenn Nutzer auf eine neue Seite klicken oder die Schaltflächen „Zurück“ oder „Weiter“ verwenden, führt die runPageTransition() unseres Routers eine Reihe von Versprechen aus. Mithilfe von Promises konnten wir die Animationen sorgfältig orchestrieren und die Async-Natur von CSS-Animationen und das dynamische Laden von Inhalten rationalisieren.

class Router {

    init() {
    window.addEventListener('popstate', e => this.runPageTransition());
    }

    runPageTransition() {
    let endPage = this.state.end.page;

    this.fire('page-transition-start');              // 1. Let current page know it's starting.

    IOWA.PageAnimation.runExitAnimation()            // 2. Play exist animation sequence.
        .then(() => {
        IOWA.Elements.LazyPages.selected = endPage;  // 3. Activate new page in <lazy-pages>.
        this.state.current = this.parseUrl(this.state.end.href);
        })
        .then(() => IOWA.PageAnimation.runEnterAnimation())  // 4. Play entry animation sequence.
        .then(() => this.fire('page-transition-done')) // 5. Tell new page transitions are done.
        .catch(e => IOWA.Util.reportError(e));
    }

}

Wie Sie im Abschnitt „DRY bleiben: Gemeinsame Funktionen auf allen Seiten“ bereits wissen, warten Seiten auf die DOM-Ereignisse page-transition-start und page-transition-done. Jetzt sehen Sie, wo diese Ereignisse ausgelöst werden.

Wir haben die Web Animations API anstelle der runEnterAnimation/runExitAnimation-Hilfsfunktionen verwendet. Bei runExitAnimation greifen wir auf einige DOM-Knoten (Masthead und Hauptinhaltsbereich) zu, deklarieren den Beginn und das Ende jeder Animation und erstellen eine GroupEffect, um die beiden parallel auszuführen:

function runExitAnimation(section) {
    let main = section.querySelector('.slide-up');
    let masthead = section.querySelector('.masthead');

    let start = {transform: 'translate(0,0)', opacity: 1};
    let end = {transform: 'translate(0,-100px)', opacity: 0};
    let opts = {duration: 400, easing: 'cubic-bezier(.4, 0, .2, 1)'};
    let opts_delay = {duration: 400, delay: 200};

    return new GroupEffect([
    new KeyframeEffect(masthead, [start, end], opts),
    new KeyframeEffect(main, [{opacity: 1}, {opacity: 0}], opts_delay)
    ]);
}

Ändern Sie einfach das Array, um die Ansichtsübergänge detaillierter (oder weniger detailliert) zu gestalten.

Scrolleffekte

IOWA hat einige interessante Effekte, wenn Sie auf der Seite scrollen. Die erste ist die unverankerte Aktionsschaltfläche, über die Nutzer zum Anfang der Seite zurückkehren können:

    <a href="#" tabindex="-1" aria-hidden="true" aria-label="back to top" onclick="backToTop">
      <paper-fab icon="io:expand-less" noink tabindex="-1"></paper-fab>
    </a>

Das flüssige Scrollen wird mit den App-Layoutelementen von Polymer implementiert. Sie bieten standardmäßige Scrolleffekte wie anklickbare Navigationsleisten, Schatten, Farb- und Hintergrundübergänge, Parallaxeneffekte und flüssiges Scrollen.

    // Smooth scrolling the back to top FAB.
    function backToTop(e) {
      e.preventDefault();

      Polymer.AppLayout.scroll({top: 0, behavior: 'smooth',
                                target: document.documentElement});

      e.target.blur();  // Kick focus back to the page so user starts from the top of the doc.
    }

Außerdem haben wir die <app-layout>-Elemente für die fixierte Navigation verwendet. Wie Sie im Video sehen, wird die Anzeige ausgeblendet, wenn Nutzer auf der Seite nach unten scrollen, und wieder eingeblendet, wenn sie nach oben scrollen.

Fixierte Navigationsleisten
Fest fixierte Navigationsleisten für das Scrollen mit .

Wir haben das <app-header>-Element so gut wie unverändert verwendet. Es war einfach, es einzufügen und ansprechende Scrolleffekte in der App zu erzielen. Natürlich hätten wir sie auch selbst implementieren können, aber da die Details bereits in einer wiederverwendbaren Komponente codiert waren, haben wir viel Zeit gespart.

Deklarieren Sie das Element. Passen Sie sie mit Attributen an. Fertig!

    <app-header reveals condenses effects="fade-background waterfall"></app-header>

Fazit

Für die progressive Web-App der I/O konnten wir dank Webkomponenten und der vorgefertigten Material Design-Widgets von Polymer in wenigen Wochen ein komplettes Frontend erstellen. Die Funktionen der nativen APIs (benutzerdefinierte Elemente, Shadow DOM, <template>) eignen sich von Natur aus für die Dynamik einer SPA. Wiederverwendbarkeit spart viel Zeit.

Wenn Sie selbst eine progressive Web-App erstellen möchten, sehen Sie sich die App-Toolbox an. Die App Toolbox von Polymer ist eine Sammlung von Komponenten, Tools und Vorlagen zum Erstellen von PWAs mit Polymer. So können Sie ganz einfach loslegen.