Cómo compilar la Progressive Web App de Google I/O 2016

Casa de Iowa

Resumen

Descubre cómo creamos una app de una sola página con componentes web, Polymer y Material Design, y cómo la lanzamos en producción en Google.com.

Resultados

  • Mayor participación que la app nativa (4:06 min en la Web móvil frente a 2:40 min de Android).
  • Primer procesamiento de imagen 450 ms más rápido para los usuarios recurrentes gracias al almacenamiento en caché del service worker
  • El 84% de los visitantes asistieron a los Service Worker
  • "Agregar a la pantalla principal" subió más de un 900% en comparación con 2015.
  • El 3.8% de los usuarios se quedó sin conexión, pero siguió generando 11,000 vistas de página.
  • El 50% de los usuarios que accedieron a sus cuentas habilitaron las notificaciones.
  • Se enviaron 536,000 notificaciones a los usuarios (el 12% las trajo de vuelta).
  • El 99% de los navegadores de los usuarios admitían los polyfills de los componentes web.

Descripción general

Este año, tuve el placer de trabajar en la app web progresiva de Google I/O 2016, llamada cariñosamente "IOWA". Se prioriza para dispositivos móviles, funciona sin conexión y está muy inspirada en Material Design.

IOWA es una aplicación de una sola página (SPA) que se compila con componentes web, Polymer y Firebase, y tiene un backend extenso escrito en App Engine (Go). Almacena por adelantado el contenido en la caché mediante un service worker, carga páginas nuevas de forma dinámica, realiza una transición fluida entre vistas y reutiliza el contenido después de la primera carga.

En este caso de éxito, repasaré algunas de las decisiones arquitectónicas más interesantes que tomamos para el frontend. Si te interesa el código fuente, revísalo en GitHub.

Ver en GitHub

Cómo crear una SPA con componentes web

Cada página como componente

Uno de los aspectos principales de nuestro frontend es que se centra en los componentes web. De hecho, cada página de nuestra SPA es 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>

¿Por qué hicimos esto? La primera razón es que este código es legible. Como usuario que lee por primera vez, es totalmente obvio qué es cada página en nuestra aplicación. La segunda razón es que los componentes web tienen algunas buenas propiedades para crear una SPA. Muchas de las frustraciones comunes (la administración de estados, la activación de vistas y el alcance de los estilos) desaparecen debido a las funciones inherentes del elemento <template>, los elementos personalizados y Shadow DOM. Son herramientas para desarrolladores que se incorporan al navegador. ¿Por qué no aprovecharlos?

Al crear un elemento personalizado para cada página, obtuvimos mucho gratis:

  • Administración del ciclo de vida de las páginas
  • Se estableció el alcance de CSS/HTML específico de la página.
  • Todo el contenido CSS/HTML/JS específico de una página se empaqueta y carga según sea necesario.
  • Las vistas se pueden reutilizar. Debido a que las páginas son nodos del DOM, la vista se modifica con solo agregarlas o quitarlas.
  • Los futuros encargados de mantenimiento pueden entender nuestra app simplemente manipulando el lenguaje de marcado.
  • El lenguaje de marcado renderizado por servidor puede mejorarse progresivamente a medida que el navegador registre y actualice las definiciones de elementos.
  • Los elementos personalizados tienen un modelo de herencia. El código DRY es un buen código.
  • ... y muchas más cosas.

En IOWA, aprovechamos al máximo estos beneficios. Analicemos algunos de los detalles.

Páginas que se activan de forma dinámica

El elemento <template> es la forma estándar del navegador para crear lenguaje de marcado reutilizable. <template> tiene dos características que las SPA pueden aprovechar. En primer lugar, todo lo que esté dentro de <template> estará inerte hasta que se cree una instancia de la plantilla. En segundo lugar, el navegador analiza el lenguaje de marcado, pero no se puede acceder al contenido desde la página principal. Es un fragmento de lenguaje de marcado real y reutilizable. Por ejemplo:

<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 los <template> con algunos elementos personalizados de tipo de extensión, es decir, <template is="dom-if"> y <template is="dom-repeat">. Ambos son elementos personalizados que extienden <template> con capacidades adicionales. Y, gracias a la naturaleza declarativa de los componentes web, ambos hacen exactamente lo que esperas. El primer componente marca el lenguaje de marcado en función de un condicional. La segunda repite el lenguaje de marcado para cada elemento de una lista (modelo de datos).

¿Cómo usa IOWA estos elementos de extensión?

Como recordarás, cada página de IOWA es un componente web. Sin embargo, sería absurdo declarar cada componente en la primera carga. Eso significaría crear una instancia de cada página cuando la aplicación se cargue por primera vez. No queríamos afectar el rendimiento de la carga inicial, especialmente porque algunos usuarios solo navegan a 1 o 2 páginas.

Nuestra solución fue hacer trampa. En IOWA, unimos los elementos de cada página en una <template is="dom-if"> para que su contenido no se cargue durante el primer inicio. Luego, activaremos las páginas cuando el atributo name de la plantilla coincida con la URL. El componente web <lazy-pages> controla toda esta lógica por nosotros. La marca se ve más o menos así:

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

Lo que me gusta de esto es que cada página está analizada y lista para usarse cuando se carga, pero su CSS/HTML/JS solo se ejecuta on demand (cuando su superior <template> tiene un sello). Vistas dinámicas y diferidas con componentes web desapercibidos.

Mejoras futuras

Cuando se carga la página por primera vez, se cargan todas las importaciones de HTML de cada página a la vez. Una mejora obvia sería la carga diferida de las definiciones de elementos solo cuando sean necesarias. Polymer también ofrece un buen asistente para la carga asíncrona de importaciones HTML:

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

IOWA no hace esto porque a) es diferida y b) no está claro el aumento del rendimiento que habríamos obtenido. La primera pintura ya duraba aproximadamente 1 s.

Administración del ciclo de vida de la página

La API de Custom Elements define las "devoluciones de llamada de ciclo de vida" para administrar el estado de un componente. Cuando implementas estos métodos, obtienes hooks gratuitos en la vida de 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.
}

Fue fácil aprovechar estas devoluciones de llamada en IOWA. Recuerda que cada página es un nodo del DOM independiente. Para navegar hacia una "vista nueva" en nuestra SPA, solo se debe adjuntar un nodo al DOM y quitar otro.

Usamos attachedCallback para realizar el trabajo de configuración (estado de inicio, adjunta objetos de escucha de eventos). Cuando los usuarios navegan a una página diferente, detachedCallback realiza una limpieza (quita los objetos de escucha, restablece el estado compartido). También expandimos las devoluciones de llamada de ciclo de vida nativas con varias de nuestras propias:

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

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

Estas adiciones útiles fueron útiles para retrasar el trabajo y minimizar el bloqueo entre las transiciones de página. Explicaré eso después.

Integración de funcionalidades comunes en las páginas

La herencia es una función muy útil de los elementos personalizados. Proporciona un modelo de herencia estándar para la Web.

Lamentablemente, Polymer 1.0 todavía implementa la herencia de elementos al momento de escribirlo. Mientras tanto, la función de comportamientos de Polymer era igual de útil. Los comportamientos son solo mezclas.

En lugar de crear la misma plataforma de API en todas las páginas, tenía sentido usar DRY-poner en la base de código mediante la creación de combinaciones compartidas. Por ejemplo, PageBehavior define propiedades o métodos comunes que necesitan todas las páginas de nuestra 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};

Como puedes ver, PageBehavior realiza tareas comunes que se ejecutan cuando se visita una página nueva. Funciones como actualizar el document.title, restablecer la posición de desplazamiento y configurar objetos de escucha de eventos para efectos de desplazamiento y navegación secundaria

Las páginas individuales usan PageBehavior cargándolo como una dependencia y usando behaviors. También son libres de anular sus propiedades o métodos básicos si es necesario. A modo de ejemplo, esto es lo que anula la “subclase” de la página principal:

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>

Cómo compartir estilos

Para compartir estilos entre diferentes componentes de nuestra app, usamos los módulos de estilo compartido de Polymer. Los módulos de estilo te permiten definir un fragmento de CSS una vez y reutilizarlo en diferentes lugares de una app. Para nosotros, "distintos lugares" significaba distintos componentes.

En IOWA, creamos shared-app-styles para compartir colores, tipografía y clases de diseño entre páginas y otros componentes que creamos.

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>

Aquí, <style include="shared-app-styles"></style> es la sintaxis de Polymer para decir "incluir los estilos en el módulo llamado "shared-app-styles".

Compartiendo el estado de la aplicación

A estas alturas, sabes que cada página de nuestra app es un elemento personalizado. Lo he dicho un millón de veces. Muy bien, pero si cada página tiene un componente web independiente, es posible que te preguntes cómo compartimos el estado en la app.

IOWA usa una técnica similar a la inyección de dependencias (Angular) o redux (React) para compartir el estado. Creamos una propiedad global de app y colgamos las subpropiedades compartidas. app se pasa por nuestra aplicación inyectándolo en cada componente que necesita sus datos. El uso de las funciones de vinculación de datos de Polymer facilita esta tarea, ya que podemos hacer el cableado sin escribir ningún código:

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

El elemento <google-signin> actualiza su propiedad user cuando los usuarios acceden a nuestra app. Como esa propiedad está vinculada a app.currentUser, cualquier página que desee acceder al usuario actual solo debe vincularse a app y leer la subpropiedad currentUser. Por sí sola, esta técnica es útil para compartir el estado en la app. Sin embargo, otro beneficio fue que creamos un elemento de acceso único y reutilizamos sus resultados en todo el sitio. Haz lo mismo para las consultas de medios. Hubiera sido un desperdicio si cada página duplicara el acceso o creara su propio conjunto de consultas de medios. En cambio, los componentes responsables de la funcionalidad o los datos en toda la app existen a nivel de la app.

Transiciones de página

A medida que navegues por la aplicación web de Google I/O, notarás sus transiciones de página elegantes (a la Material Design).

Transiciones de la página de IOWA en acción.
Transiciones de páginas de IOWA en acción.

Cuando los usuarios navegan a una página nueva, ocurre una secuencia de cosas:

  1. El panel de navegación superior desliza una barra de selección al nuevo vínculo.
  2. El encabezado de la página se atenúa.
  3. El contenido de la página se desliza hacia abajo y luego se atenúa.
  4. Al revertir esas animaciones, aparecen el encabezado y el contenido de la página nueva.
  5. (Opcional) La página nueva realiza tareas de inicialización adicionales.

Uno de nuestros desafíos fue descubrir cómo crear esta elegante transición sin sacrificar el rendimiento. Se desarrolla mucho trabajo dinámico y jank no fue bienvenido en nuestra fiesta. Nuestra solución fue una combinación de la API de Web Animations y Promises. Usar ambos juntos nos dio versatilidad, un sistema de animación listo para usar y un control detallado para minimizar los bloqueos de das.

Cómo funciona

Cuando los usuarios hacen clic para ir a una página nueva (o presionan Atrás/adelante), el runPageTransition() de nuestro router ejecuta una serie de promesas. El uso de promesas nos permitió organizar cuidadosamente las animaciones y racionalizar la "asincronía" de las animaciones de CSS y cargar contenido de forma dinámica.

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

}

Recuperación de la sección "Cómo mantener elementos DRY: funcionalidad común en todas las páginas"; las páginas escuchan los eventos del DOM page-transition-start y page-transition-done. Ahora sabes dónde se activan esos eventos.

Usamos la API de Web Animations en lugar de los asistentes runEnterAnimation/runExitAnimation. En el caso de runExitAnimation, tomamos un par de nodos del DOM (el masthead y el área de contenido principal), declaramos el inicio y el final de cada animación, y creamos un GroupEffect para ejecutar ambos en paralelo:

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

Solo debes modificar el array para que las transiciones de vistas sean más (o menos) elaboradas.

Efectos de desplazamiento

IOWA tiene algunos efectos interesantes cuando te desplazas por la página. El primero es nuestro botón de acción flotante (BAF) que lleva a los usuarios de vuelta a la parte superior de la página:

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

El desplazamiento suave se implementa usando los elementos de diseño de apps de Polymer. Proporcionan efectos de desplazamiento listos para usar, como navegaciones superiores fijas o recurrentes, sombras paralelas, transiciones de color y fondo, efectos de paralaje y desplazamiento 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.
    }

Otro lugar en el que usamos los elementos <app-layout> fue para la navegación fija. Como puedes ver en el video, el vínculo desaparece cuando los usuarios se desplazan hacia abajo en la página y regresa cuando se desplazan hacia arriba.

Navegación de desplazamiento pegajosa
Navegaciones de desplazamiento persistentes con

Usamos el elemento <app-header> prácticamente sin modificaciones. Fue fácil colocarlos y obtener efectos de desplazamiento sofisticados en la app. Claro, podríamos haberlos implementado nosotros, pero tener los detalles ya codificados en un componente reutilizable nos ahorraba mucho tiempo.

Declara el elemento. Personalízala con atributos. ¡Listo!

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

Conclusión

Para la app web progresiva de I/O, pudimos compilar un frontend completo en varias semanas gracias a los componentes web y los widgets prefabricados de Polymer. Las características de las APIs nativas (Elementos personalizados, Shadow DOM, <template>) se prestan naturalmente al dinamismo de una SPA. La reutilización ahorra muchísimo tiempo.

Si te interesa crear tu propia app web progresiva, consulta la Caja de herramientas de apps. App Toolbox de Polymer es una colección de componentes, herramientas y plantillas para crear AWP con Polymer. Es una forma fácil de ponerse en marcha.