Zusammenfassung
Hier erfährst du, wie wir mit Webkomponenten, Polymer und Material Design eine einseitige App entwickelt 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 weiterhin 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. Sie ist an Mobilgeräte ausgerichtet, funktioniert vollständig 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. 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. Wenn Sie sich für den Quellcode interessieren, finden Sie ihn hier auf GitHub.
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 gemacht? 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 die Erstellung eines benutzerdefinierten Elements für jede Seite bekamen wir eine Menge geschenkt:
- 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 es sich bei Seiten um DOM-Knoten handelt, ändert sich die Ansicht, wenn Sie sie einfach hinzufügen oder entfernen.
- Künftige Pflegekräfte können unsere App einfach verstehen, indem sie das Markup erfassen.
- Serverseitig gerendertes Markup kann nach und nach verbessert werden, wenn Elementdefinitionen vom Browser registriert und aktualisiert werden.
- Für benutzerdefinierte Elemente gibt es ein Vererbungsmodell. DRY-Code ist guter Code.
- …und vieles mehr.
Wir haben diese Vorteile in IOWA voll genutzt. Sehen wir uns ein paar 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 parst der Browser das Markup, aber die Inhalte sind von der Hauptseite aus nicht erreichbar. Es ist ein echter, wiederverwendbarer Markup-Code. 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 Typerweiterungselemente, nämlich <template is="dom-if">
und <template is="dom-repeat">
. Beide sind benutzerdefinierte Elemente, die <template>
um zusätzliche Funktionen erweitern. Und dank der deklarativen Natur von Webkomponenten funktionieren beide genau so, wie Sie es erwarten.
Die erste Komponente markiert 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 Typerweiterungselemente?
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 zu betrügen. In IOWA umschließen wir das Element jeder Seite in einem <template is="dom-if">
, damit sein 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 ungefähr 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>
Besonders gut gefällt mir, dass jede Seite geparst und einsatzbereit ist, wenn die Seite geladen wird. CSS/HTML/JS wird jedoch nur bei Bedarf ausgeführt (wenn die übergeordnete <template>
mit einem Stempel versehen ist). Dynamische und Lazy-Ansichten mit Webkomponenten sind einfach super.
Zukü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 verfügt auch über eine praktische Hilfsfunktion zum asynchronen 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 eine andere Seite aufrufen, führt der detachedCallback
eine Bereinigung durch (Listener werden entfernt und der Freigabestatus wird 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 war eine nützliche Ergänzung, um die Arbeit zu verzögern und Ruckler zwischen den Seitenübergängen zu minimieren. Mehr dazu später.
Austrocknen gängiger Funktionen auf den Seiten
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.
Einzelne Seiten verwenden PageBehavior
, indem sie als Abhängigkeit geladen werden 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>
Freigabestile
Um Stile für verschiedene Komponenten in unserer App zu teilen, haben wir die gemeinsamen Stilmodule von Polymer verwendet. Mit Style-Modulen 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 bereits, 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 durch die Einschleusung in jede Komponente, die die Daten benötigt, an die Anwendung übergeben. 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 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 verschwendet, für jede Seite die Anmeldung zu duplizieren oder eigene Medienabfragen 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.
Wenn Nutzer eine neue Seite aufrufen, geschieht Folgendes:
- In der oberen Navigationsleiste wird eine Auswahlleiste zum neuen Link verschoben.
- Die Überschrift der Seite verblasst.
- Der Inhalt der Seite gleitet nach unten und wird dann ausgeblendet.
- Wenn Sie diese Animationen rückwärts abspielen, werden die Überschrift und der Inhalt der neuen Seite angezeigt.
- 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 detaillierte Einstellungen, 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 Helfer runEnterAnimation
/runExitAnimation
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)
]);
}
Passen Sie einfach das Array an, um die Übergänge zwischen den Ansichten etwas aufwendiger (oder weniger) 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:
<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 können, verschwindet es, wenn Nutzende auf der Seite nach unten scrollen, und kehren zurück, wenn sie zurück nach oben scrollen.
Wir haben das <app-header>
-Element im Prinzip unverändert verwendet. Es ließ sich leicht einfügen und liefert tolle Scrolleffekte in der App. Natürlich hätten wir sie selbst implementieren können, aber wenn die Details bereits in einer wiederverwendbaren Komponente codiert wurden, spart das enorm viel Zeit.
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 der Webkomponenten und der vorgefertigten Material Design-Widgets von Polymer ein komplettes Front-End in wenigen Wochen 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.