Creazione dell'app web progressiva Google I/O 2016

Casa in Iowa

Riepilogo

Scopri come abbiamo creato un'app a pagina singola 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).
  • Prima visualizzazione più veloce di 450 ms per gli utenti di ritorno grazie alla memorizzazione nella cache dei worker di servizio
  • L'84% dei visitatori supportava il service worker
  • Le salvataggi con l'opzione Aggiungi a schermata Home sono aumentati del 900% rispetto al 2015.
  • Il 3,8% degli utenti è offline, ma ha continuato a generare 11.000 visualizzazioni di pagina.
  • Il 50% degli utenti che hanno eseguito l'accesso ha abilitato le notifiche.
  • Sono state inviate 536.000 notifiche agli utenti (il 12% le ha restituite).
  • Il 99% dei browser degli utenti supportava i polyfill dei componenti web

Panoramica

Quest'anno ho avuto il piacere di lavorare all'app web progressiva di Google I/O 2016, chiamata affettuosamente "IOWA". È una funzionalità incentrata sui dispositivi mobili, funziona completamente offline e si ispira fortemente al material design.

IOWA è un'applicazione a pagina singola (SPA) creata utilizzando componenti web, Polymer e Firebase e ha un backend completo scritto in App Engine (Go). Prememorizza i contenuti nella cache utilizzando un service worker, carica dinamicamente le nuove pagine, effettua la transizione tra le visualizzazioni in modo agevole e riutilizza i contenuti dopo il primo caricamento.

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

Visualizza su GitHub

Creazione di un'app SPA utilizzando componenti web

Ogni pagina come componente

Uno degli aspetti fondamentali del nostro frontend è che è incentrato sui componenti web. Infatti, ogni pagina della nostra 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é abbiamo preso questa decisione? Il primo motivo è che il codice è leggibile. Chi legge per la prima volta al suo interno è molto ovvio per quanto riguarda ogni pagina dell'app. Il secondo motivo è che i componenti web hanno alcune valide proprietà per la costruzione di un'APS. Molti problemi comuni (gestione dello stato, attivazione della visualizzazione, ambito degli stili) scompaiono 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 molto gratuitamente:

  • Gestione del ciclo di vita delle pagine.
  • CSS/HTML con ambito specifico per la pagina.
  • Tutto il codice CSS/HTML/JS specifico di una pagina viene raggruppato e caricato insieme in base alle esigenze.
  • Le visualizzazioni sono riutilizzabili. Poiché le pagine sono nodi DOM, la visualizzazione cambia semplicemente aggiungendole o rimuovendole.
  • I futuri manutentori possono capire la nostra app semplicemente abbondando il markup.
  • Il markup per il rendering dal server può essere progressivamente migliorato man mano che le definizioni degli elementi vengono registrate e aggiornate dal browser.
  • Gli elementi personalizzati hanno un modello di ereditarietà. Il codice DRY è un buon codice.
  • …e molto altro.

Abbiamo sfruttato appieno questi vantaggi in IOWA. Esaminiamo alcuni dettagli.

Attivazione dinamica delle pagine

L'elemento <template> è il modo standard del browser per creare markup riutilizzabili. <template> ha due caratteristiche che le SPA possono sfruttare. Innanzitutto, qualsiasi elemento all'interno di <template> è 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. Si tratta di un vero e proprio frammento di markup 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"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer espande gli elementi <template> con alcuni elementi personalizzati di tipo estensione, ovvero <template is="dom-if"> e <template is="dom-repeat">. Entrambi sono elementi personalizzati che estendono <template> con funzionalità aggiuntive. Grazie alla natura dichiarativa dei componenti web, entrambi fanno esattamente quello che ti aspetti. Il primo componente contrassegna il markup in base a un'espressione 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 sciocco dichiarare ogni componente al primo caricamento. Ciò significa che dovrai creare un'istanza di ogni pagina al primo caricamento dell'app. Non volevamo peggiorare le prestazioni di caricamento iniziale, soprattutto perché alcuni utenti visitano solo 1 o 2 pagine.

La nostra soluzione è stata barare. In IOWA, racchiudiamo l'elemento di ogni pagina in un <template is="dom-if"> in modo che i relativi contenuti non vengano caricati al primo avvio. Attiva le pagine 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 approccio è che ogni pagina viene analizzata e pronta all'uso al caricamento, ma il relativo CSS/HTML/JS viene eseguito solo su richiesta (quando il relativo elemento principale <template> viene stampato). Visualizzazioni dinamiche e lazy con componenti web FTW.

Miglioramenti futuri

Al primo caricamento della pagina, vengono caricate contemporaneamente tutte le importazioni HTML per ogni pagina. Un miglioramento evidente sarebbe il caricamento differito delle definizioni degli elementi solo quando sono necessarie. Polymer ha anche un pratico helper per il caricamento asincrono delle importazioni HTML:

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

IOWA non lo fa perché a) siamo pigri e b) non è chiaro quale sarebbe stato l'aumento del rendimento. Il nostro primo paint era già di circa 1 secondo.

Gestione del ciclo di vita delle pagine

L'API Custom Elements definisce i "richiami di ciclo di vita" per gestire lo stato di un componente. Quando implementi questi metodi, hai hook gratuiti nel ciclo di 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. Per passare a una "nuova visualizzazione" nella nostra SPA, è sufficiente collegare un nodo al DOM e rimuoverne un altro.

Abbiamo utilizzato attachedCallback per eseguire le operazioni di configurazione (stato di avvio, collegamento di listener di eventi). Quando gli utenti passano a un'altra pagina, detachedCallback esegue la pulizia (rimuove gli ascoltatori, reimposta lo stato condiviso). Abbiamo anche ampliato i callback del ciclo di vita nativo con diversi nostri:

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

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

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

Rimuovere le funzionalità comuni dalle pagine

L'eredità è una funzionalità potente di Custom Elements. Fornisce un modello di ereditarietà standard per il web.

Purtroppo Polymer 1.0 non ha ancora implementato l'ereditarietà degli elementi al momento della stesura di questo documento. Nel frattempo, la funzionalità Comportamenti di Polymer era altrettanto utile. I comportamenti sono solo mixin.

Invece di creare la stessa API su tutte le pagine, aveva senso svuotare la base del codebase creando mixin condivisi. Ad esempio, PageBehavior definisce proprietà/metodi comuni di cui hanno bisogno tutte le pagine della nostra app:

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 che vengono eseguite quando viene visitata una nuova pagina. Ad esempio, l'aggiornamento di document.title, la reimpostazione della posizione di scorrimento e la configurazione dei listener di eventi per gli effetti di scorrimento e di navigazione secondaria.

Le singole pagine utilizzano PageBehavior caricandolo come dipendenza e utilizzando behaviors. Inoltre, sono liberi di sostituire le loro proprietà/metodi di base, se necessario. Ad esempio, ecco cosa sostituisce la "sottoclasse" della home page:

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>

Condividere stili

Per condividere gli stili tra i diversi componenti della nostra app, abbiamo utilizzato i moduli di stili condivisi di Polymer. I moduli di stile ti consentono di definire un blocco di CSS una volta e di riutilizzarlo in posizioni diverse dell'app. Per noi, "posizioni diverse" significavano componenti diversi.

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

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>

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

Condivisione dello stato dell'applicazione

A questo punto sai che ogni pagina della nostra app è un elemento personalizzato. L'ho detto un milione di volte. Ok, ma se ogni pagina è un componente web autonomo, è possibile che ti stia chiedendo come condividiamo lo stato nell'app.

IOWA utilizza una tecnica simile all'iniezione di dipendenze (Angular) o a Redux (React) per condividere lo stato. Abbiamo creato una proprietà app globale e abbiamo bloccato le proprietà secondarie condivise. app viene trasmesso all'interno della nostra applicazione iniettandolo in ogni componente che necessita dei suoi dati. L'utilizzo delle funzionalità di associazione dei dati di Polymer semplifica questa operazione, poiché possiamo eseguire il cablaggio senza scrivere 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 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 creato un elemento di accesso singolo e riutilizzato i relativi risultati nel sito. Lo stesso vale per le query supporti. Sarebbe stato uno spreco duplicare l'accesso per ogni pagina o creare un proprio set di query sui contenuti multimediali. Invece, i componenti responsabili della funzionalità/dei dati a livello di app esistono a livello di app.

Transizioni di pagina

Navigando nell'app web Google I/O, noterai delle eleganti transizioni delle pagine (à 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. Il menu di navigazione superiore fa scorrere una barra di selezione al nuovo link.
  2. L'intestazione della pagina svanisce.
  3. I contenuti della pagina scivolano verso il basso e svaniscono.
  4. Se inverti queste animazioni, vengono visualizzati l'intestazione e i contenuti della nuova pagina.
  5. (Facoltativo) La nuova pagina esegue operazioni di inizializzazione aggiuntive.

Una delle nostre sfide è stata capire come creare questa transizione fluida senza sacrificare il rendimento. Il lavoro è molto dinamico e il junk non era il benvenuto. La nostra soluzione era una combinazione di API Web Animations e Promises. L'uso combinato di questi due strumenti ci ha permesso di aggiungere versatilità, un sistema di animazione plug-and-play e un controllo granulare per ridurre al minimo i das jank.

Come funziona

Quando gli utenti fanno clic su una nuova pagina (o premono Indietro/Avanti), runPageTransition() del nostro router fa la sua magia eseguendo una serie di promesse. L'utilizzo delle promesse ci ha permesso di orchestrare attentamente le animazioni e di razionalizzare l'"asincronicità" 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));
    }

}

Ricorda dalla sezione "Mantieni DRY: funzionalità comuni tra le pagine", le pagine ascoltano gli eventi DOM page-transition-start e page-transition-done. Ora puoi vedere dove vengono attivati questi eventi.

Abbiamo utilizzato l'API Web Animations anziché gli aiuti runEnterAnimation/runExitAnimation. Nel caso di runExitAnimation, recuperiamo un paio di nodi DOM (il masthead e l'area dei contenuti principali), dichiariamo l'inizio/la fine di ogni animazione e creiamo un GroupEffect per eseguirli 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>

La scorrimento fluido è implementato utilizzando gli elementi app-layout di Polymer. Offrono effetti di scorrimento immediati, come menu di navigazione in alto fisso/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 altro punto in cui abbiamo utilizzato gli elementi <app-layout> è stato per il menu di navigazione fisso. Come puoi vedere nel video, scompare quando gli utenti scorrono verso il basso nella pagina e ricompare quando scorrono verso l'alto.

Barre di navigazione con scorrimento fisso
Barre di navigazione con scorrimento permanente che utilizzano .

Abbiamo utilizzato l'elemento <app-header> praticamente così com'è. È stato facile inserirlo e ottenere effetti di scorrimento elaborati nell'app. Certo, avremmo potuto implementarli noi stessi, ma avere i dettagli già codificati in un componente riutilizzabile ci ha fatto risparmiare un sacco di tempo.

Dichiara l'elemento. Personalizzarlo con gli 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 Material Design predefiniti di Polymer. Le funzionalità delle API native (elementi personalizzati, Shadow DOM, <template>) si prestano naturalmente al dinamismo di un'applicazione SPA. La riutilizzabilità consente di risparmiare un sacco di tempo.

Se ti interessa creare una tua app web progressiva, consulta la cassetta degli attrezzi per le app. Strumenti per app di Polymer è una raccolta di componenti, strumenti e modelli per la creazione di PWA con Polymer. È un modo semplice per iniziare a utilizzare la piattaforma.