Creazione dell'app web progressiva Google I/O 2016

Casa in Iowa

Riepilogo

Scopri come abbiamo creato un'app su una sola pagina utilizzando componenti web, Polymer e Material Design e come l'abbiamo lanciata in produzione su Google.com.

Risultati

  • Maggiore coinvolgimento rispetto all'app nativa (4:06 min di web mobile rispetto a 2:40 min di Android).
  • First Paint più veloce di 450 ms per gli utenti di ritorno grazie alla memorizzazione nella cache del service worker
  • L'84% dei visitatori ha supportato il service worker
  • I salvataggi della funzionalità Aggiungi alla schermata Home sono aumentati del 900% rispetto al 2015.
  • Il 3,8% degli utenti è passato offline, ma ha continuato a generare 11.000 visualizzazioni di pagina.
  • Il 50% degli utenti che hanno eseguito l'accesso ha attivato le notifiche.
  • Gli utenti hanno ricevuto 536.000 notifiche (il 12% le ha riportate).
  • Il 99% dei browser degli utenti supportava i polyfill dei componenti web

Panoramica

Quest'anno ho avuto il piacere di lavorare sull'app web progressiva Google I/O 2016, chiamata affettuosamente "IOWA". È ottimizzato per i dispositivi mobili, funziona completamente offline e si ispira moltissimo al material design.

IOWA è un'applicazione a pagina singola (SPA) creata utilizzando componenti web, Polymer e Firebase, e ha un backend esteso scritto in App Engine (Go). Pre-memorizza nella cache i contenuti utilizzando un service worker, carica dinamicamente nuove pagine, passa facilmente da una visualizzazione all'altra e riutilizza i contenuti dopo il primo caricamento.

In questo case study, esamineremo alcune delle decisioni più interessanti sull'architettura che abbiamo preso per il frontend. Se ti interessa il codice sorgente, dai un'occhiata su GitHub.

Visualizza su GitHub

Creazione di un'APS utilizzando componenti web

Ogni pagina come componente

Uno degli aspetti principali del nostro frontend è che è incentrato sui componenti web. Infatti, ogni pagina del nostro SPA è un componente web:

    <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>

Perché lo abbiamo fatto? Il primo motivo è che il codice è leggibile. Chi legge per la prima volta è assolutamente ovvio di quale sia ogni pagina della nostra app. Il secondo motivo è che i componenti web hanno alcune utili proprietà per la creazione di un'APS. Molte difficoltà comuni (gestione dello stato, attivazione delle visualizzazioni, selezione dell'ambito degli stili) spariscono grazie alle funzionalità intrinseche dell'elemento <template>, degli elementi personalizzati e di Shadow DOM. Si tratta di strumenti per sviluppatori integrati nel browser. Perché non approfittarne?

Creando un elemento personalizzato per ogni pagina, abbiamo ottenuto moltissimo:

  • Gestione del ciclo di vita delle pagine.
  • CSS/HTML con ambito specifico per la pagina.
  • Tutti i CSS/HTML/JS specifici di una pagina vengono raggruppati e caricati insieme secondo necessità.
  • Le viste sono riutilizzabili. Poiché le pagine sono nodi DOM, la semplice aggiunta o rimozione cambia la visualizzazione.
  • I futuri gestori possono comprendere la nostra app semplicemente filtrando il markup.
  • Il markup sottoposto a rendering dal server può essere migliorato progressivamente man mano che le definizioni degli elementi vengono registrate e aggiornate dal browser.
  • Gli elementi personalizzati hanno un modello di ereditarietà. Il codice DRY è valido.
  • ...molte altre cose.

Abbiamo sfruttato al meglio questi vantaggi in IOWA. Esaminiamo alcuni dei dettagli.

Attivazione dinamica delle pagine

L'elemento <template> è il modo standard del browser per creare markup riutilizzabile. <template> ha due caratteristiche che le APS possono sfruttare. Innanzitutto, qualsiasi elemento all'interno di <template> rimane inerte fino a quando non viene creata un'istanza del modello. In secondo luogo, il browser analizza il markup ma i contenuti non sono raggiungibili dalla pagina principale. È un blocco di markup vero e riutilizzabile. Ad esempio:

<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" autoplay></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer extends i <template> con alcuni elementi personalizzati di estensione del tipo, ovvero <template is="dom-if"> e <template is="dom-repeat">. Entrambi sono elementi personalizzati che estendono <template> con funzionalità aggiuntive. Inoltre, grazie alla natura dichiarativa dei componenti web, entrambi fanno esattamente ciò che ci si aspetta. Il primo componente applica un markup al markup in base a un condizionale. Il secondo ripete il markup per ogni elemento di un elenco (modello dei dati).

In che modo IOWA utilizza questi elementi di estensione del tipo?

Come ricorderai, ogni pagina in IOWA è un componente web. Tuttavia, sarebbe stupido dichiarare ogni componente al primo caricamento. Ciò significa creare un'istanza di ogni pagina al primo caricamento dell'app. Non volevamo compromettere le nostre prestazioni di caricamento iniziale, soprattutto perché alcuni utenti si spostano solo su una o due pagine.

La nostra soluzione era barare. In IOWA, includiamo l'elemento di ogni pagina in un <template is="dom-if"> in modo che i relativi contenuti non vengano caricati al primo avvio. Le pagine vengono quindi attivate quando l'attributo name del modello corrisponde all'URL. Il componente web <lazy-pages> gestisce tutta questa logica per noi. Il markup ha il seguente aspetto:

<!-- 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>

Ciò che mi piace di questo aspetto è che ogni pagina viene analizzata e pronta all'uso quando viene caricata, ma i relativi CSS/HTML/JS vengono eseguiti solo su richiesta (quando il relativo <template> principale viene timbrato). Visualizzazioni dinamiche e lazy con i componenti web FTW.

Miglioramenti futuri

Quando la pagina viene caricata per la prima volta, carichiamo contemporaneamente tutte le importazioni HTML di ogni pagina. Un miglioramento ovvio sarebbe il caricamento lento delle definizioni degli elementi solo quando sono necessarie. Polymer è anche un utile aiutante per il caricamento asincrono delle importazioni HTML:

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

IOWA non lo fa perché a) siamo diventati pigri e b) non è chiaro quanto miglioramento delle prestazioni avremmo visto. La nostra prima visualizzazione era già circa 1 secondo.

Gestione del ciclo di vita delle pagine

L'API Custom Elements definisce i "callback del ciclo di vita" per gestire lo stato di un componente. Quando implementi questi metodi, ottieni hook senza costi nella vita di un componente:

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.
}

È stato facile sfruttare questi callback in IOWA. Ricorda che ogni pagina è un nodo DOM autonomo. Il passaggio a una "nuova vista" nel nostro SPA riguarda il collegamento di un nodo al DOM e la rimozione di un altro.

Abbiamo utilizzato attachedCallback per eseguire la configurazione (stato di inizializzazione, collegamento di listener di eventi). Quando gli utenti passano a una pagina diversa, detachedCallback esegue la pulizia (rimuove i listener, reimposta lo stato condiviso). Abbiamo anche ampliato i callback nativi del ciclo di vita con molti dei nostri:

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

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

Queste sono state aggiunte utili per ritardare il lavoro e ridurre al minimo il jank tra le transizioni di pagina. Ne parleremo più avanti.

Rendere disponibili le funzionalità comuni tra le pagine.

L'ereditarietà è una potente funzionalità degli elementi personalizzati. Fornisce un modello di ereditarietà standard per il web.

Sfortunatamente, Polymer 1.0 ha ancora da implementare l'ereditarietà degli elementi al momento della scrittura. Nel frattempo, la funzionalità Behaviors di Polymer si è rivelata altrettanto utile. I comportamenti sono solo mixins.

Anziché creare la stessa superficie API su tutte le pagine, aveva senso DRY-up il codebase creando mixin condivisi. Ad esempio, PageBehavior definisce proprietà/metodi comuni di cui tutte le pagine della nostra app hanno bisogno:

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};

Come puoi vedere, PageBehavior esegue attività comuni quando viene visitata una nuova pagina. Ad esempio, puoi aggiornare document.title, reimpostare la posizione di scorrimento e configurare i listener di eventi per gli effetti di scorrimento e navigazione secondaria.

Le singole pagine utilizzano PageBehavior caricandolo come dipendenza e usando behaviors. Sono inoltre liberi di sostituire le sue proprietà/metodi di base, se necessario. Ad esempio, la "sottoclasse" della home page sostituisce la seguente classe:

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>

Stili di condivisione

Per condividere stili tra diversi componenti della nostra app, abbiamo utilizzato i moduli di stile condivisi di Polymer. I moduli di stile consentono di definire un blocco di CSS una volta sola e di riutilizzarlo in punti diversi di un'app. Per noi, "luoghi diversi" significano componenti diversi.

In IOWA, abbiamo creato shared-app-styles per condividere colori, tipografia e classi di layout tra le pagine e gli altri componenti creati.

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>

In questo caso, <style include="shared-app-styles"></style> è la sintassi di Polymer per indicare "includi gli stili nel modulo denominato "shared-app-styles".

Condivisione dello stato dell'applicazione

Ormai sai che ogni pagina della nostra app è un elemento personalizzato. L'ho detto un milione di volte. Se invece ogni pagina è un componente web indipendente, potresti chiederti come condividiamo lo stato nell'app.

IOWA utilizza una tecnica simile all'inserimento delle dipendenze (Angular) o al redux (React) per la condivisione dello stato. Abbiamo creato una proprietà app globale e vi abbiamo aggiunto le proprietà secondarie condivise. app viene passato nella nostra applicazione iniettandola in ogni componente che ha bisogno dei suoi dati. L'utilizzo delle funzionalità di associazione dei dati di Polymer semplifica il processo, poiché possiamo eseguire il cablaggio senza scrivere alcun codice:

<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>

L'elemento <google-signin> aggiorna la sua proprietà user quando gli utenti accedono alla nostra app. Poiché questa proprietà è associata a app.currentUser, qualsiasi pagina che vuole accedere all'utente corrente deve semplicemente associarsi a app e leggere la proprietà secondaria currentUser. Questa tecnica è utile per condividere lo stato nell'app. Tuttavia, un altro vantaggio è che abbiamo finito per creare un elemento di accesso singolo e riutilizzarne i risultati su tutto il sito. Lo stesso vale per le query supporti. Sarebbe uno spreco per ogni pagina duplicare l'accesso o creare il proprio insieme di query supporti. Al contrario, i componenti responsabili della funzionalità/dei dati a livello di app esistono a livello di app.

Transizioni di pagina

Man mano che esplori l'app web Google I/O, noterai delle fluide transizioni di pagina (à la material design).

Le transizioni di pagina di IOWA in azione.
Transizioni di pagina di IOWA in azione.

Quando gli utenti visitano una nuova pagina, si verifica una sequenza di cose:

  1. La barra di navigazione superiore fa scorrere una barra di selezione verso il nuovo link.
  2. L'intestazione della pagina scompare.
  3. I contenuti della pagina scorrono verso il basso e poi spariscono.
  4. Invertendo queste animazioni, vengono visualizzati l'intestazione e i contenuti della nuova pagina.
  5. (Facoltativo) La nuova pagina esegue ulteriori operazioni di inizializzazione.

Una delle nostre sfide è stata quella di capire come realizzare questa transizione fluida senza sacrificare le prestazioni. C'è molto lavoro dinamico in corso e jank non è stato il benvenuto alla nostra festa. La nostra soluzione era una combinazione dell'API Web Animations e di Promises. L'utilizzo combinato dei due prodotti ci ha dato versatilità, un sistema di animazione plug-and-play e un controllo granulare per ridurre al minimo i das.

Come funziona

Quando gli utenti fanno clic su una nuova pagina (o premino Indietro/Avanti), il runPageTransition() del nostro router fa il suo magia esaminando una serie di Promesse. L'utilizzo di Promises ci ha permesso di orchestrare attentamente le animazioni e ha contribuito a razionalizzare la "asincronia" delle animazioni CSS e il caricamento dinamico dei contenuti.

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));
    }

}

Richiamo della sezione "Modalità DRY: funzionalità comuni in tutte le pagine", le pagine restano in ascolto degli eventi DOM page-transition-start e page-transition-done. Ora stai vedendo dove vengono attivati questi eventi.

Abbiamo usato l'API Web Animations anziché gli helper runEnterAnimation/runExitAnimation. Nel caso di runExitAnimation, prendiamo un paio di nodi DOM (il masthead e l'area dei contenuti principale), dichiariamo l'inizio/fine di ogni animazione e creiamo un GroupEffect per eseguire i due in parallelo:

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)
    ]);
}

Basta modificare l'array per rendere le transizioni di visualizzazione più (o meno) elaborate.

Effetti di scorrimento

IOWA ha alcuni effetti interessanti quando scorri la pagina. Il primo è il nostro Floating Action Button (FAB) che riporta gli utenti nella parte superiore della pagina:

    <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>

Lo scorrimento fluido viene implementato utilizzando gli elementi di layout delle app di Polymer. Forniscono effetti di scorrimento pronti all'uso come menu di navigazione superiori persistenti o di ritorno, ombre, transizioni di colore e sfondo, effetti di parallasse e scorrimento fluido.

    // 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.
    }

Un'altra posizione in cui abbiamo utilizzato gli elementi <app-layout> è stata quella del menu di navigazione fisso. Come puoi vedere dal video, scompare quando l'utente scorre la pagina verso il basso e torna quando scorre la pagina verso l'alto.

Navigazione a scorrimento continuo
Navigazioni di scorrimento persistenti che utilizzano .

Abbiamo usato l'elemento <app-header> più o meno così com'è. È stato facile inserire e ottenere effetti di scorrimento fantasiosi nell'app. Certo, avremmo potuto implementarli autonomamente, ma avere i dettagli già codificati in un componente riutilizzabile ha fatto risparmiare molto tempo.

Dichiara l'elemento. Personalizzalo con degli attributi. È tutto.

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

Conclusione

Per l'app web progressiva I/O, siamo riusciti a creare un intero frontend in diverse settimane grazie ai componenti web e ai widget di Material Design predefiniti di Polymer. Le funzionalità delle API native (Custom Element, Shadow DOM, <template>) si prestano naturalmente al dinamismo di una SPA. La riutilizzabilità ti fa risparmiare tantissimo tempo.

Se vuoi creare una tua app web progressiva, dai un'occhiata agli Strumenti per app. Polymer's App Toolbox è una raccolta di componenti, strumenti e modelli per la creazione di PWA con Polymer. È un modo semplice per iniziare.