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

Zuhause in Iowa

Zusammenfassung

Hier erfährst du, wie wir eine Single-Page-App mit Webkomponenten, Polymer und Material Design entwickelt und auf Google.com in Produktion gebracht haben.

Ergebnisse

  • Mehr Interaktionen als bei der systemeigenen App (mobiles Web 4:06 Min. vs. Android 2:40 Min.)
  • 450 ms schnellerer First Paint für wiederkehrende Nutzende dank Service Worker-Caching
  • 84% der Besucher unterstützten einen Service Worker.
  • Die Einsparung von „Zum Startbildschirm hinzufügen“ stieg gegenüber 2015 um über 900 %.
  • 3,8% der Nutzer gingen offline, erzielten aber weiterhin 11.000 Seitenaufrufe.
  • 50% der angemeldeten Nutzer haben Benachrichtigungen aktiviert.
  • 536.000 Benachrichtigungen wurden an Nutzer gesendet (12% haben sie zurückgeholt).
  • 99% der Browser der Nutzer unterstützten die Webkomponenten „Polyfills“.

Überblick

Dieses Jahr hatte ich das Vergnügen, an der Progressive Web-App der Google I/O 2016 mit dem liebevoll „IOWA“ zu arbeiten. Sie ist mobil an erster Stelle, funktioniert offline und ist stark vom Material Design inspiriert.

IOWA ist eine Single-Page-Anwendung (SPA), die mit Webkomponenten, Polymer und Firebase erstellt wurde und ein umfangreiches Back-End hat, das in App Engine (Go) geschrieben ist. Inhalte werden mithilfe eines Service Worker vorab im Cache gespeichert, neue Seiten dynamisch geladen, reibungslos zwischen Ansichten gewechselt und Inhalte nach dem ersten Laden wiederverwendet.

In dieser Fallstudie geht es um einige der interessanteren Architekturentscheidungen, die wir für das Front-End getroffen haben. Wenn Sie sich für den Quellcode interessieren, können Sie ihn auf GitHub aufrufen.

Auf GitHub ansehen

SPA mit Webkomponenten erstellen

Jede Seite als Komponente

Einer der Hauptaspekte unseres Front-Ends ist, dass es sich auf Webkomponenten konzentriert. Jede Seite in unserer SPA ist eine Webkomponente:

    <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? Der erste Grund ist, dass dieser Code lesbar ist. Wenn Sie zum ersten Mal eine Seite unserer App lesen, ist klar, worum es sich bei jeder Seite unserer App handelt. Der zweite Grund ist, dass Webkomponenten einige schöne Eigenschaften für die Erstellung einer SPA haben. Die meisten Probleme (Statusverwaltung, Aktivierung der Ansicht, Stilumfang) lassen sich durch die Funktionen des <template>-Elements, benutzerdefinierte Elemente und Shadow DOM wegzaubern. Dies sind Entwicklertools, die in den Browser integriert sind. Warum diese nicht?

Durch das Erstellen eines benutzerdefinierten Elements für jede Seite haben wir eine Menge kostenlos erhalten:

  • Verwaltung des Seitenlebenszyklus.
  • Ein begrenzter CSS-/HTML-Code für die Seite.
  • Alle seitenspezifischen CSS-, HTML- und JS-Elemente werden gebündelt und nach Bedarf zusammen geladen.
  • Datenansichten sind wiederverwendbar. Da es sich bei Seiten um DOM-Knoten handelt, ändert sich die Ansicht, wenn sie einfach hinzugefügt oder entfernt werden.
  • Künftige Betreuer können unsere App ganz einfach verstehen, indem sie das Markup groben.
  • Das vom Server gerenderte Markup kann schrittweise verbessert werden, wenn Elementdefinitionen vom Browser registriert und aktualisiert werden.
  • Für benutzerdefinierte Elemente gilt ein Übernahmemodell. Der DRY-Code ist ein guter Code.
  • ...viele weitere Dinge.

Bei IOWA haben wir diese Vorteile voll genutzt. Sehen wir uns einige Details an.

Seiten dynamisch aktivieren

Das <template>-Element ist die Standardmethode des Browsers zum Erstellen wiederverwendbarer Markups. <template> hat zwei Merkmale, von denen SPAs profitieren können. Erstens ist alles innerhalb der <template> inaktiv, bis eine Instanz der Vorlage erstellt wird. Zweitens: Der Browser parst das Markup, aber die Inhalte sind von der Hauptseite aus nicht erreichbar. Es ist ein echter, wiederverwendbarer Markup-Chunk. 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 extends die <template>-Elemente um einige benutzerdefinierte Elemente für Typerweiterungen, nämlich <template is="dom-if"> und <template is="dom-repeat">. Beide sind benutzerdefinierte Elemente, die <template> mit zusätzlichen Funktionen erweitern. Und dank der deklarativen Natur von Webkomponenten funktionieren beide genau das, was Sie erwarten. Die erste Komponente stempelt das Markup basierend auf einer Bedingung. In der zweiten wird das Markup für jedes Element in einer Liste (Datenmodell) wiederholt.

Wie verwendet IOWA diese Typerweiterungselemente?

Wie Sie sich vielleicht erinnern, ist jede Seite in IOWA eine Webkomponente. Es wäre jedoch verwirrend, jede Komponente beim ersten Laden zu deklarieren. Dies bedeutet, dass eine Instanz jeder Seite beim ersten Laden der App erstellt wird. Wir wollten unsere anfängliche Ladeleistung nicht beeinträchtigen, zumal einige Nutzer nur zu ein oder zwei Seiten wechseln.

Unsere Lösung war zu betrügen. In IOWA verpacken wir das Element jeder Seite in einem <template is="dom-if">, sodass sein Inhalt beim ersten Start nicht geladen wird. Dann werden Seiten aktiviert, wenn das Attribut name der Vorlage mit der URL übereinstimmt. Die <lazy-pages>-Webkomponente übernimmt diese 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>

Das gefällt mir, dass jede Seite geparst ist und einsatzbereit ist, wenn die Seite geladen wird. CSS/HTML/JS werden jedoch nur bei Bedarf ausgeführt (wenn das übergeordnete <template> mit einem Stempel versehen ist). Dynamische und verzögerte Ansichten unter Verwendung von Webkomponenten für FTW

Künftige Verbesserungen

Beim ersten Laden der Seite werden alle HTML-Importe für jede Seite auf einmal geladen. Eine offensichtliche Verbesserung wäre das Lazy Loading der Elementdefinitionen nur bei Bedarf. Polymer verfügt auch über eine hilfreiche Hilfsfunktion für HTML-Importe mit asynchronem Laden:

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

IOWA macht dies nicht, weil a) es faul waren und b) es unklar ist, welche Leistungssteigerung wir hätten erzielen können. Unser erstes Mal ging bereits 1 Sek. an.

Verwaltung des Seitenlebenszyklus

Die Custom Elements API definiert Lebenszyklus-Callbacks zur Verwaltung des Status einer Komponente. Wenn Sie diese Methoden implementieren, erhalten Sie kostenlose Hooks für 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 Nutzung dieser Callbacks in IOWA war einfach. Denken Sie daran, dass jede Seite ein eigenständiger DOM-Knoten ist. Wenn Sie zu einer „neuen Ansicht“ in unserer SPA wechseln möchten, müssen Sie einen Knoten mit dem DOM verknüpfen und einen anderen entfernen.

Zur Ausführung der Einrichtung (Init-Status, Anhängen von Event-Listenern) wurde attachedCallback verwendet. Wenn Nutzer eine andere Seite aufrufen, führt detachedCallback eine Bereinigung aus. Dabei werden Listener entfernt und der gemeinsame Status zurückgesetzt. 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.
}

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

Gängige Funktionen auf Seiten wegzaubern

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

Leider konnte in Polymer 1.0 zum Zeitpunkt der Erstellung dieses Dokuments noch keine Elementübernahme implementiert werden. In der Zwischenzeit war die Polymer-Funktion Verhalten genauso nützlich. Verhaltensweisen sind nur Mixins.

Anstatt auf allen Seiten dieselbe API-Oberfläche zu erstellen, ist es sinnvoll, die Codebasis durch gemeinsame Mixins zu DRY zu machen. Mit PageBehavior werden beispielsweise allgemeine Attribute und Methoden definiert, die alle Seiten in unserer Anwendung 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 beim Besuch einer neuen Seite ausgeführt werden. Dazu gehören das Aktualisieren von document.title, das Zurücksetzen der Scrollposition und das Einrichten von Event-Listenern für Scroll- und Unternavigationseffekte.

Einzelne Seiten verwenden PageBehavior, indem sie sie als Abhängigkeit laden und behaviors verwenden. Außerdem können sie bei Bedarf die Basiseigenschaften und -methoden überschreiben. Als Beispiel überschreibt die „Unterklasse“ unserer Startseite Folgendes:

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>

Freigabestile

Um Stile für verschiedene Komponenten unserer App zu nutzen, haben wir die gemeinsam genutzten Stilmodule von Polymer verwendet. Mit Stilmodulen können Sie einen CSS-Chunk einmal definieren und an verschiedenen Stellen in einer App wiederverwenden. Für uns bedeuteten „verschiedene Orte“ unterschiedliche Komponenten.

In IOWA haben wir shared-app-styles erstellt, um Farben, Typografie und Layoutklassen auf Seiten und anderen von uns erstellten 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 ist <style include="shared-app-styles"></style> die Polymer-Syntax für die Aufforderung „die Stile in das Modul mit dem Namen ‚shared-app-styles‘ aufnehmen“.

Anwendungsstatus teilen

Inzwischen wissen Sie, dass jede Seite in unserer App ein benutzerdefiniertes Element ist. Ich habe es schon eine Million Mal gesagt. Okay, aber wenn jede Seite eine eigenständige Webkomponente ist, fragen Sie sich vielleicht, wie wir den Zustand innerhalb der App teilen.

IOWA verwendet für die Freigabe des Status ein Verfahren, das der Abhängigkeitsinjektion (Angular) oder Redux (React) ähnelt. Wir haben eine globale app-Property erstellt und untergeordnete Properties darauf hinzugefügt. app wird durch die Anwendung übergeben, indem es in jede Komponente eingeschleust wird, die die Daten benötigt. Mit den Datenbindungsfunktionen von Polymer ist dies ganz einfach, da wir die Verdrahtung vornehmen können, ohne Code schreiben zu müssen:

<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 <google-signin>-Element aktualisiert seine user-Eigenschaft, wenn sich Nutzer in unserer App anmelden. Da diese an app.currentUser gebunden ist, muss jede Seite, die auf den aktuellen Nutzer zugreifen möchte, einfach eine Bindung an app herstellen und die untergeordnete currentUser-Property lesen. Diese Methode ist für sich genommen nützlich, um den Status in der gesamten App zu teilen. Ein weiterer Vorteil bestand jedoch darin, dass wir ein Element für die Einmalanmeldung (SSO) erstellt und seine Ergebnisse auf der gesamten Website wiederverwendet haben. Dasselbe gilt für Medienabfragen. Es wäre aufwendig, auf jeder Seite doppelte Anmeldungen zu suchen oder eigene Medienabfragen zu erstellen. Stattdessen gibt es auf App-Ebene Komponenten, die für die Funktionen und Daten in der gesamten App verantwortlich sind.

Seitenübergänge

Beim Navigieren in der Google I/O-Webanwendung werden Sie die reibungslosen Seitenübergänge (à la material Design) bemerken.

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

Wenn Nutzende zu einer neuen Seite navigieren, passiert eine Abfolge von Dingen:

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

Eine unserer Herausforderungen bestand darin, diese reibungslose Umstellung ohne Leistungseinbußen zu schaffen. Es findet viel dynamischer Arbeit statt und Januar war auf unserer Party nicht willkommen. Unsere Lösung bestand aus einer Kombination aus der Web Animations API und Promises. Dank der Kombination dieser beiden Tools sind wir vielseitig, ein Plug-and-Play-Animationssystem und eine präzise Steuerung zur Minimierung von Verzögerungen möglich.

Funktionsweise

Wenn Nutzer auf eine neue Seite klicken oder „Zurück/Vorwärts“ auswählen, durchläuft der runPageTransition() des Routers eine Reihe von Promise-Objekten. Mithilfe von Promises konnten wir die Animationen sorgfältig orchestrieren und die „Asynchronität“ 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));
    }

}

Recall aus dem Abschnitt Dringend: gemeinsame Funktion auf Seiten enthält, erkennen 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-Helper verwendet. Bei runExitAnimation erfassen wir einige DOM-Knoten (Masthead und Hauptinhaltsbereich), deklarieren Anfang und Ende jeder Animation und erstellen einen 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 Übergänge der Ansicht komplexer oder weniger aufwendig zu gestalten.

Scrolleffekte

IOWA hat einige interessante Effekte, wenn Sie auf der Seite scrollen. Die erste ist die unverankerte Aktionsschaltfläche, mit der Nutzer zurück an den Anfang der Seite gelangen:

    <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 reibungslose Scrollen wird mithilfe der App-Layout-Elemente von Polymer implementiert. Sie bieten praktische Scroll-Effekte wie fixierte oder wiederkehrende obere Navigationsleisten, Schlagschatten, Farb- und Hintergrundübergänge, Parallaxe-Effekte und optimiertes 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.
    }

Die <app-layout>-Elemente haben wir auch für die fixierte Navigationsleiste verwendet. Wie Sie im Video sehen können, verschwindet es, wenn der Nutzer auf der Seite nach unten scrollt, und kehrt zurück, wenn er wieder nach oben scrollt.

Navigationen für fixiertes Scrollen
Fixierte Scroll-Navigationen mit .

Wir haben das <app-header>-Element praktisch so verwendet, wie es ist. Es war einfach, in der App hineinzuklicken und ausgefallene Scrolleffekte zu nutzen. Wir hätten sie natürlich selbst implementieren können, aber die Details bereits in einer wiederverwendbaren Komponente codiert zu haben, war eine enorme Zeitersparnis.

Deklarieren Sie das Element. Sie können es mit Attributen anpassen. Fertig!

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

Fazit

Dank der Webkomponenten und der vorgefertigten Material Design-Widgets von Polymer konnten wir für die progressive I/O-Webanwendung in einigen Wochen ein komplettes Front-End erstellen. Die Funktionen nativer APIs (Custom Elements, Shadow DOM, <template>) passen von Natur aus zur 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 umfasst eine Sammlung von Komponenten, Tools und Vorlagen für die Erstellung von PWAs mit Polymer. So können Sie ganz einfach loslegen.