Como criar o Progressive Web App do Google I/O 2016

Página inicial em Iowa

Resumo

Saiba como criamos um app de página única usando componentes da Web, Polymer e o Material Design e o lançamos para produção no Google.com.

Resultados

  • mais engajamento do que o app nativo (4:06 min na Web para dispositivos móveis vs. 2:40 min no Android).
  • Primeira exibição 450 ms mais rápida para usuários recorrentes graças ao armazenamento em cache do service worker
  • 84% dos visitantes aceitaram o service worker
  • Os itens salvos na tela inicial aumentaram 900% em comparação com 2015.
  • 3,8% dos usuários ficaram off-line, mas continuaram gerando 11 mil visualizações de página.
  • 50% dos usuários conectados ativaram as notificações.
  • 536 mil notificações foram enviadas aos usuários (12% os trouxeram de volta).
  • 99% dos navegadores dos usuários eram compatíveis com os polyfills de componentes da Web

Visão geral

Este ano, tive o prazer de trabalhar no App Web Progressivo do Google I/O 2016, carinhosamente chamado "IOWA". Ele prioriza dispositivos móveis, funciona totalmente off-line e é muito inspirado no material design.

O IOWA é um aplicativo de página única (SPA, na sigla em inglês) criado com componentes da Web, Polymer e Firebase, além de ter um back-end abrangente escrito no App Engine (Go). Ele pré-armazena em cache o conteúdo usando um service worker, carrega dinamicamente novas páginas, faz a transição entre visualizações e reutiliza o conteúdo após o primeiro carregamento.

Neste estudo de caso, vou analisar algumas das decisões de arquitetura mais interessantes que tomamos para o front-end. Se você tiver interesse no código-fonte, confira no GitHub.

Ver no GitHub

Como criar um SPA usando componentes da Web

Cada página como componente

Um dos aspectos principais do nosso front-end é que ele é centrado nos componentes da Web. Na verdade, cada página do SPA é um componente da 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 que fizemos isso? A primeira razão é que esse código é legível. Como leitor iniciante, é completamente óbvio qual é cada página do nosso aplicativo. A segunda razão é que os componentes da Web têm algumas propriedades interessantes para a criação de um SPA. Muitas frustrações comuns (gerenciamento de estado, ativação de visualização, escopo de estilo) desaparecem graças aos recursos inerentes do elemento <template>, dos elementos personalizados e do Shadow DOM. São ferramentas de desenvolvedor que estão sendo incorporadas ao navegador. Por que não aproveitá-los?

Ao criar um elemento personalizado para cada página, recebemos vários recursos sem custo financeiro:

  • Gerenciamento do ciclo de vida da página.
  • CSS/HTML com escopo específico para a página.
  • Todos os CSS/HTML/JS específicos de uma página são agrupados e carregados conforme a necessidade.
  • As visualizações são reutilizáveis. Como as páginas são nós DOM, basta adicioná-las ou removê-las para alterar a visualização.
  • Futuros mantenedores podem entender nosso aplicativo simplesmente grotando a marcação.
  • A marcação renderizada pelo servidor pode ser aprimorada progressivamente à medida que as definições de elementos são registradas e atualizadas pelo navegador.
  • Os elementos personalizados têm um modelo de herança. O código DRY é bom.
  • ... muito mais coisas.

Aproveitamos esses benefícios ao máximo na IOWA. Vamos conferir alguns detalhes.

Ativação dinâmica de páginas

O elemento <template> é a maneira padrão do navegador para criar uma marcação reutilizável. <template> tem duas características que os SPAs podem aproveitar. Primeiro, tudo o que estiver dentro do <template> fica inerte até que uma instância do modelo seja criada. Em segundo lugar, o navegador analisa a marcação, mas o conteúdo não pode ser acessado na página principal. É um bloco de marcação verdadeiro e reutilizável. Exemplo:

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

A plataforma Polymer extends as <template> com alguns elementos personalizados de extensão de tipo, como <template is="dom-if"> e <template is="dom-repeat">. Ambos são elementos personalizados que estendem <template> com recursos extras. E, graças à natureza declarativa dos componentes da Web, ambos fazem exatamente o que você espera. O primeiro componente marca a marcação com base em uma condicional. A segunda repete a marcação para cada item de uma lista (modelo de dados).

Como o IOWA usa esses elementos de extensão de tipo?

Todas as páginas da IOWA são componentes da Web. No entanto, seria inadequado declarar todos os componentes no primeiro carregamento. Isso significaria criar uma instância de cada página quando o app for carregado pela primeira vez. Não queríamos prejudicar nosso desempenho de carregamento inicial, especialmente porque alguns usuários navegariam somente para uma ou duas páginas.

Nossa solução foi uma trapaça. Em IOWA, unimos o elemento de cada página em um <template is="dom-if"> para que o conteúdo não carregue na primeira inicialização. Em seguida, ativamos as páginas quando o atributo name do modelo corresponde ao URL. O componente da Web <lazy-pages> processa toda essa lógica. A marcação é semelhante a esta:

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

O que gosto disso é que todas as páginas são analisadas e ficam prontas para uso quando a página é carregada, mas o CSS/HTML/JS dela só é executado sob demanda (quando o <template> principal é carimbado). Visualizações dinâmicas e lentas usando componentes da Web agora.

Melhorias futuras

Quando a página é carregada pela primeira vez, carregamos todas as importações HTML de cada página de uma vez. Uma melhoria óbvia seria fazer o carregamento lento das definições de elementos somente quando elas forem necessárias. O Polymer também tem um ótimo auxiliar para o carregamento assíncrono de importações HTML:

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

O IOWA não faz isso porque a) caminhamos com lentidão e b) não está claro o tamanho do aumento de desempenho que poderíamos ter visto. Nossa primeira pintura já foi de aproximadamente 1 segundo.

Gerenciamento do ciclo de vida da página

A API Custom Elements define "callbacks do ciclo de vida" para gerenciar o estado de um componente. Ao implementar esses métodos, você recebe ganchos livres para a vida útil de um 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.
}

Foi fácil aproveitar esses retornos de chamada em IOWA. Lembre-se de que cada página é um nó DOM independente. Para navegar até uma "nova visualização" no SPA, é preciso anexar um nó ao DOM e remover outro.

Usamos o attachedCallback para realizar o trabalho de configuração (estado init e listeners de eventos anexados). Quando os usuários navegam para uma página diferente, o detachedCallback faz a limpeza (remove listeners e redefine o estado compartilhado). Também expandimos os callbacks do ciclo de vida nativos com vários próprios:

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

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

Essas foram adições úteis para atrasar o trabalho e minimizar a instabilidade entre as transições de página. Falaremos sobre isso mais adiante.

Eliminar funcionalidades comuns entre páginas

A herança é um recurso avançado dos elementos personalizados. Ele fornece um modelo de herança padrão para a Web.

Infelizmente, o Polymer 1.0 ainda não implementou a herança de elementos até o momento em que este artigo foi escrito. Enquanto isso, o recurso Behaviors da Polymer foi igualmente útil. Os comportamentos são apenas mixins.

Em vez de criar a mesma plataforma de API em todas as páginas, fazia sentido criar mixins compartilhados para refinar a base de código. Por exemplo, PageBehavior define propriedades/métodos comuns de que todas as páginas do nosso app precisam:

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 você pode notar, o PageBehavior realiza tarefas comuns que são executadas quando uma nova página é visitada. Coisas como atualizar o document.title, redefinir a posição de rolagem e configurar listeners de eventos para efeitos de rolagem e subnavegação.

Páginas individuais usam o PageBehavior carregando-o como uma dependência e usando behaviors. Eles também podem substituir as propriedades/métodos base, se necessário. Por exemplo, veja o que a "subclasse" da nossa página inicial substitui:

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>

Estilos de compartilhamento

Para compartilhar estilos entre diferentes componentes do app, usamos módulos de estilo compartilhados da Polymer. Com os módulos de estilo, é possível definir um bloco de CSS uma vez e reutilizá-lo em diferentes lugares do app. Para nós, "lugares diferentes" significava componentes distintos.

Na IOWA, criamos shared-app-styles para compartilhar cores, tipografia e classes de layout entre as páginas e outros componentes que fizemos.

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>

Aqui, <style include="shared-app-styles"></style> é a sintaxe do Polymer para dizer "incluir os estilos no módulo chamado "shared-app-styles".

Compartilhando o estado do aplicativo

Agora você sabe que cada página do nosso aplicativo é um elemento personalizado. Eu já disse isso um milhão de vezes. Certo, mas se cada página é um componente da Web independente, você pode estar se perguntando como compartilhamos o estado em todo o app.

O IOWA usa uma técnica semelhante à injeção de dependência (Angular) ou redux (React) para compartilhamento de estado. Criamos uma propriedade app global e desativamos as subpropriedades compartilhadas nela. O app é transmitido ao nosso aplicativo injetando-o em cada componente que precisa dos dados. O uso dos recursos de vinculação de dados do Polymer facilita esse processo, já que é possível fazer a fiação sem escrever nenhum 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>

O elemento <google-signin> atualiza a propriedade user quando os usuários fazem login no app. Como essa propriedade está vinculada a app.currentUser, qualquer página que queira acessar o usuário atual só precisa se vincular a app e ler a subpropriedade currentUser. Essa técnica sozinha é útil para compartilhar o estado em todo o app. No entanto, outro benefício foi que acabamos criando um único elemento de login e reutilizando os resultados em todo o site. O mesmo se aplica às consultas de mídia. Seria um desperdício para cada página duplicar o login ou criar o próprio conjunto de consultas de mídia. Em vez disso, há componentes responsáveis pela funcionalidade/dados em todo o app no nível do app.

Transições de página

Ao navegar no app da Web do Google I/O, você vai perceber as transições incríveis entre as páginas (à la Material Design).

Transições de página do IOWA em ação.
Transições de página do IOWA em ação.

Quando os usuários navegam para uma nova página, acontece uma sequência de coisas:

  1. A navegação superior desliza uma barra de seleção para o novo link.
  2. O título da página desaparece.
  3. O conteúdo da página desliza para baixo e desaparece.
  4. Ao inverter essas animações, o título e o conteúdo da nova página são exibidos.
  5. (Opcional) A nova página faz o trabalho de inicialização adicional.

Um dos nossos desafios foi descobrir como criar essa transição sofisticada sem sacrificar o desempenho. Há muito trabalho dinâmico acontecendo, e a instabilidade não foi bem-vinda na nossa festa. Nossa solução foi uma combinação da API Web Animations e promessas. Usar os dois juntos nos deu versatilidade, um sistema de animação plug-and-play e controle granular para minimizar a instabilidade das das.

Como funciona

Quando os usuários clicam em uma nova página (ou voltam/avançadas), o runPageTransition() do roteador faz sua mágica executando uma série de promessas. O uso de promessas nos permitiu orquestrar cuidadosamente as animações e ajudou a racionalizar a "assincronia" das animações CSS e do carregamento dinâmico de conteúdo.

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

}

Relembre da seção "Como manter a leitura: funcionalidade comum nas páginas". As páginas detectam os eventos DOM page-transition-start e page-transition-done. Agora você sabe onde esses eventos são acionados.

Usamos a API Web Animations em vez dos auxiliares runEnterAnimation/runExitAnimation. No caso do runExitAnimation, coletamos alguns nós do DOM (o masthead e a área do conteúdo principal), declaramos o início/fim de cada animação e criamos um GroupEffect para executar os dois em 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)
    ]);
}

Basta modificar a matriz para tornar as transições de visualização mais (ou menos) elaboradas.

Efeitos de rolagem

O IOWA tem alguns efeitos interessantes quando você rola a página. O primeiro é o botão de ação flutuante (FAB, na sigla em inglês), que leva os usuários de volta ao topo da 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>

A rolagem suave é implementada usando os elementos de layout do app da Polymer. Eles oferecem efeitos de rolagem prontos para uso, como navegação superior fixa/de retorno, sombra projetada, transições de cor e plano de fundo, efeitos de paralaxe e rolagem 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.
    }

Outro lugar em que usamos os elementos <app-layout> foi para a navegação fixa. Como você pode ver no vídeo, ele desaparece quando os usuários rolam a página para baixo e retorna ao rolar para cima.

Navegação fixa com rolagem
Navegação fixa de rolagem usando .

Usamos o elemento <app-header> praticamente no estado em que se encontra. Foi fácil usar e usar efeitos de rolagem sofisticados no app. Podemos ter implementado esses elementos nós mesmos, mas ter os detalhes já codificados em um componente reutilizável economiza muito tempo.

Declare o elemento. Personalize com atributos. Pronto!

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

Conclusão

Para o Progressive Web App de E/S, criamos um front-end completo em várias semanas graças aos componentes da Web e aos widgets de Material Design predefinidos da Polymer. Os recursos das APIs nativas (elementos personalizados, Shadow DOM, <template>) se adaptam naturalmente ao dinamismo de um SPA. A reutilização economiza muito tempo.

Se você tiver interesse em criar seu próprio Progressive Web App, confira o App Toolbox. A App Toolbox da Polymer é um conjunto de componentes, ferramentas e modelos para criar PWAs com essa plataforma. É uma forma fácil de começar a usar o produto.