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

Página principal de Iowa

Resumen

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

Resultados

  • Más participación que la aplicación nativa (4:06 min en la Web móvil en comparación con los 2:40 min de Android).
  • Primer procesamiento de imagen 450 ms más rápido para los usuarios recurrentes gracias al almacenamiento en caché de service worker
  • El 84% de los visitantes admitía el service worker
  • Las acciones de agregar a la pantalla principal aumentaron más del 900% en comparación con 2015.
  • El 3.8% de los usuarios se desconectó, pero siguió generando 11,000 vistas de página.
  • El 50% de los usuarios que accedieron habilitaron las notificaciones.
  • Se enviaron 536,000 notificaciones a los usuarios (el 12% los recuperó).
  • El 99% de los navegadores de los usuarios admitían los polyfills de 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". Prioriza los dispositivos móviles, funciona completamente sin conexión y se inspira en gran medida en el diseño de material.

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 en caché el contenido de forma previa con un trabajador de servicio, carga páginas nuevas de forma dinámica, realiza transiciones fluidas entre vistas y reutiliza el contenido después de la primera carga.

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

Ver en GitHub

Cómo compilar un SPA con componentes web

Cada página como un 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? El primer motivo es que este código es legible. Como lector por primera vez, es completamente obvio qué es cada página de nuestra app. La segunda razón es que los componentes web tienen algunas propiedades interesantes para compilar una SPA. Muchas frustraciones comunes (administración de estado, activación de vistas, alcance de estilo) desaparecen gracias a las funciones inherentes del elemento <template>, los elementos personalizados y el Shadow DOM. Estas son herramientas para desarrolladores que se integran en el navegador. ¿Por qué no aprovecharlos?

Cuando creamos un elemento personalizado para cada página, obtuvimos mucho contenido de forma gratuita:

  • Administración del ciclo de vida de la página.
  • CSS/HTML centrado en la página
  • Todo el CSS/HTML/JS específico de una página se agrupa y se carga junto según sea necesario.
  • Las vistas se pueden reutilizar. Dado que las páginas son nodos del DOM, solo con agregarlas o quitarlas se cambia la vista.
  • Los futuros responsables del mantenimiento pueden comprender nuestra app con solo comprender el lenguaje de marcado.
  • El marcado renderizado por el servidor se puede mejorar de forma progresiva a medida que el navegador registra y actualiza las definiciones de los elementos.
  • Los elementos personalizados tienen un modelo de herencia. El código DRY es un buen código.
  • …y mucho más.

Aprovechamos al máximo estos beneficios en IOWA. Analicemos algunos de los detalles.

Activación dinámica de páginas

El elemento <template> es la forma estándar del navegador de crear un marcado reutilizable. <template> tiene dos características que los SPAs pueden aprovechar. En primer lugar, todo lo que esté dentro de <template> es inerte hasta que se crea 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 marcado 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"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer extiende los <template> con algunos elementos personalizados de extensión de tipo, como <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 marcado según un condicional. El segundo repite el marcado para cada elemento de una lista (modelo de datos).

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

Si recuerdas, cada página de IOWA es un componente web. Sin embargo, sería una tontería declarar todos los componentes en la primera carga. Eso implicaría crear una instancia de cada página cuando se cargue la app por primera vez. No queríamos perjudicar el rendimiento de carga inicial, en especial porque algunos usuarios solo navegan a 1 o 2 páginas.

Nuestra solución fue hacer trampa. En IOWA, unimos cada elemento de la página en un <template is="dom-if"> para que su contenido no se cargue en el primer inicio. Luego, activamos las páginas cuando el atributo name de la plantilla coincide con la URL. El componente web <lazy-pages> controla toda esta lógica por nosotros. El marcado se ve de la siguiente manera:

<!-- 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 se analiza y está lista para funcionar cuando se carga, pero su CSS/HTML/JS solo se ejecuta a pedido (cuando se marca su <template> superior). Vistas dinámicas y diferidas con componentes web FTW.

Mejoras futuras

Cuando se carga la página por primera vez, cargamos todas las importaciones de HTML de cada página a la vez. Una mejora obvia sería cargar de forma diferida las definiciones de elementos solo cuando sean necesarias. Polymer también tiene una buena ayuda para la carga asíncrona de importaciones de HTML:

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

IOWA no hace esto porque a) somos perezosos y b) no está claro cuánto aumento de rendimiento habríamos visto. Nuestro primer procesamiento de imagen ya era de alrededor de 1 s.

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

La API de Custom Elements define "devoluciones de llamada del ciclo de vida" para administrar el estado de un componente. Cuando implementas estos métodos, obtienes hooks gratuitos en el ciclo de 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 DOM independiente. Navegar a una "vista nueva" en nuestro SPA es cuestión de conectar un nodo al DOM y quitar otro.

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

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

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

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

Aplicar la metodología DRY a la funcionalidad común en todas las páginas

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

Lamentablemente, Polymer 1.0 aún no implementa la herencia de elementos en el momento de escribir este artículo. Mientras tanto, la función Behaviors de Polymer era igual de útil. Los comportamientos son solo mixins.

En lugar de crear la misma plataforma de API en todas las páginas, tenía sentido aplicar la técnica DRY a la base de código creando mixins compartidos. 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. Acciones como actualizar document.title, restablecer la posición de desplazamiento y configurar objetos de escucha de eventos para los efectos de desplazamiento y navegación secundaria.

Las páginas individuales usan PageBehavior cargándolo como una dependencia y usando behaviors. También pueden anular sus propiedades o métodos básicos si es necesario. A modo de ejemplo, esto es lo que anula nuestra "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 diseño 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, "diferentes lugares" significaban diferentes componentes.

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

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 "incluye los estilos en el módulo llamado "shared-app-styles".

Cómo compartir el estado de la aplicación

Ya sabes que cada página de nuestra app es un elemento personalizado. Lo dije un millón de veces. De acuerdo, pero si cada página es un componente web independiente, es posible que te preguntes cómo compartimos el estado en toda 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 app global y colgamos subpropiedades compartidas de ella. app se pasa a nuestra aplicación inyectándolo en cada componente que necesita sus datos. Usar las funciones de vinculación de datos de Polymer facilita este proceso, ya que podemos realizar la conexión 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. Dado que 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 toda la app. Sin embargo, otro beneficio fue que terminamos creando un elemento de inicio de sesión único y reutilizando sus resultados en todo el sitio. Lo mismo ocurre con las consultas de medios. Sería un desperdicio que cada página duplicara el acceso o creara su propio conjunto de consultas de contenido multimedia. En cambio, los componentes responsables de la funcionalidad o los datos de toda la app existen a nivel de la app.

Transiciones de página

Mientras navegas por la app web de Google I/O, notarás sus transiciones de página fluidas (al estilo de Material Design).

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

Cuando los usuarios navegan a una página nueva, se produce una secuencia de eventos:

  1. La barra de navegación superior desliza una barra de selección al vínculo nuevo.
  2. El encabezado de la página desaparece.
  3. El contenido de la página se desliza hacia abajo y, luego, se desvanece.
  4. Cuando se invierten esas animaciones, aparecen el encabezado y el contenido de la página nueva.
  5. (Opcional) La página nueva realiza un trabajo de inicialización adicional.

Uno de nuestros desafíos fue descubrir cómo crear esta transición elegante sin sacrificar el rendimiento. Se realiza mucho trabajo dinámico y no se admitía lag 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 plug and play y un control detallado para minimizar el bloqueo das.

Cómo funciona

Cuando los usuarios hacen clic en una página nueva (o presionan atrás o adelante), el runPageTransition() de nuestro router hace su magia ejecutando una serie de promesas. El uso de promesas nos permitió organizar cuidadosamente las animaciones y racionalizar la "asynchronicidad" de las animaciones de CSS y la carga dinámica de contenido.

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

}

Recuerda que, en la sección "Mantén la coherencia: funcionalidad común en todas las páginas", las páginas escuchan los eventos del DOM page-transition-start y page-transition-done. Ahora ves dónde se activan esos eventos.

Usamos la API de Web Animations en lugar de los ayudantes runEnterAnimation/runExitAnimation. En el caso de runExitAnimation, tomamos un par de nodos DOM (el encabezado y el área de contenido principal), declaramos el inicio o el final de cada animación y creamos un GroupEffect para ejecutar los dos 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 modifica el array para que las transiciones de vista 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 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 con los elementos de diseño de la app de Polymer. Proporcionan efectos de desplazamiento listos para usar, como navegación superior fija o que regresa, sombras paralelas, transiciones de color y fondo, efectos de paralaje y desplazamiento suave.

    // 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 el menú de navegación fijo. Como puedes ver en el video, desaparece cuando los usuarios se desplazan hacia abajo en la página y vuelve a aparecer cuando se desplazan hacia arriba.

Navegación de desplazamiento fija
Navegación de desplazamiento fija con .

Usamos el elemento <app-header> tal como está. Fue fácil agregarlo y obtener efectos de desplazamiento elegantes en la app. Por supuesto, podríamos haberlos implementado nosotros mismos, pero tener los detalles ya codificados en un componente reutilizable nos ahorró 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 de material design prediseñados de Polymer. Las funciones de las APIs nativas (Custom Elements, Shadow DOM, <template>) se prestan de forma natural al dinamismo de un SPA. La reutilización ahorra mucho tiempo.

Si te interesa crear tu propia app web progresiva, consulta la caja de herramientas de la app. La Caja de herramientas de apps de Polymer es una colección de componentes, herramientas y plantillas para compilar PWAs con Polymer. Es una forma fácil de comenzar a usarla.