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

Casa em Iowa

Resumo

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

Resultados

  • Mais engajamento do que o app nativo (4:06 min na Web para dispositivos móveis x 2:40 min no Android).
  • A primeira pintura ficou 450 ms mais rápida para usuários recorrentes graças ao armazenamento em cache do service worker
  • 84% dos visitantes apoiaram o service worker
  • O recurso "Adicionar à tela inicial" aumentou 900% em relação a 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% trouxeram de volta).
  • 99% dos navegadores dos usuários são compatíveis com os polyfills de componentes da Web

Visão geral

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

O IOWA é um aplicativo de página única (SPA) criado usando componentes da Web, Polymer e Firebase, e tem um back-end extenso escrito no App Engine (Go). Ele armazena em cache o conteúdo usando um service worker, carrega novas páginas dinamicamente, faz transições suaves entre visualizações e reutiliza o conteúdo após o primeiro carregamento.

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

Conferir no GitHub

Como criar uma SPA usando componentes da Web

Cada página como um componente

Um dos principais aspectos do nosso front-end é que ele é centrado em componentes da Web. Na verdade, cada página no nosso 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? O primeiro motivo é que esse código é legível. Para quem está lendo pela primeira vez, é completamente óbvio qual é cada página do nosso app. A segunda razão é que os componentes da Web têm algumas propriedades interessantes para criar 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>, Elementos personalizados e Shadow DOM. Essas são ferramentas para desenvolvedores integradas ao navegador. Por que não aproveitar isso?

Ao criar um elemento personalizado para cada página, ganhamos muito sem custo:

  • Gerenciamento do ciclo de vida da página.
  • CSS/HTML específico para a página.
  • Todos os CSS/HTML/JS específicos de uma página são agrupados e carregados juntos conforme necessário.
  • As visualizações são reutilizáveis. Como as páginas são nós do DOM, basta adicioná-las ou removê-las para mudar a visualização.
  • Os futuros mantenedores podem entender nosso app simplesmente analisando 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. Código DRY é um bom código.
  • …e muito mais.

Aproveitamos todos esses benefícios no IOWA. Vamos conferir alguns detalhes.

Ativar páginas dinamicamente

O elemento <template> é a maneira padrão do navegador de criar marcação reutilizável. O <template> tem duas características que os SPAs podem aproveitar. Primeiro, tudo dentro do <template> fica inativo até que uma instância do modelo seja criada. Em segundo lugar, o navegador analisou a marcação, mas o conteúdo não pode ser acessado pela página principal. É um pedaço 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>

O Polymer amplia os <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 estampa a marcação com base em uma condição. A segunda repetição de markup para cada item em uma lista (modelo de dados).

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

Lembre-se de que cada página no IOWA é um componente da Web. No entanto, seria tolice declare 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 queremos prejudicar o desempenho do carregamento inicial, especialmente porque alguns usuários só vão navegar em uma ou duas páginas.

Nossa solução foi trapacear. No IOWA, envolvemos cada elemento da página em um <template is="dom-if"> para que o conteúdo não seja carregado 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 para nós. A marcação tem esta aparência:

<!-- 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 eu gosto nisso é que cada página é analisada e fica pronta para uso quando é carregada, mas o CSS/HTML/JS só é executado sob demanda (quando o <template> pai é carimbado). Visualizações dinâmicas e preguiçosas usando componentes da Web.

Melhorias futuras

Quando a página é carregada pela primeira vez, todas as importações de HTML de cada página são carregadas de uma só vez. Uma melhoria óbvia seria carregar as definições dos elementos apenas quando elas forem necessárias. O Polymer também tem um bom auxiliar para carregamento assíncrono de importações de HTML:

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

O IOWA não faz isso porque (a) ficamos preguiçosos e (b) não está claro o quanto o aumento de desempenho teria sido. Nossa first paint já era de cerca de 1 segundo.

Gerenciamento do ciclo de vida da página

A API Custom Elements define callbacks de ciclo de vida para gerenciar o estado de um componente. Ao implementar esses métodos, você recebe ganchos sem custo financeiro na vida 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 callbacks no IOWA. Lembre-se de que cada página é um nó do DOM independente. Navegar até uma "nova visualização" na nossa SPA é uma questão de anexar um nó ao DOM e remover outro.

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

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 o problema de lentidão entre as transições de página. Falaremos mais sobre isso mais tarde.

DRY de funcionalidade comum em várias páginas

A herança é um recurso poderoso 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 no momento da escrita. Enquanto isso, o recurso Behaviors do Polymer era igualmente útil. Os comportamentos são apenas mixins.

Em vez de criar a mesma plataforma da API em todas as páginas, fazia sentido DRY-up a base de código criando mixins compartilhados. Por exemplo, PageBehavior define propriedades/métodos comuns que todas as páginas do 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 ver, PageBehavior executa tarefas comuns que são executadas quando uma nova página é visitada. Por exemplo, atualizar o document.title, redefinir a posição de rolagem e configurar listeners de eventos para efeitos de rolagem e subnavegação.

As páginas individuais usam PageBehavior carregando-o como uma dependência e usando behaviors. Eles também podem substituir as propriedades/métodos básicos, se necessário. Como exemplo, confira 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>

Como compartilhar estilos

Para compartilhar estilos entre diferentes componentes no app, usamos os módulos de estilo compartilhados do Polymer. Os módulos de estilo permitem definir um pedaço de CSS uma vez e reutilizá-lo em diferentes lugares em um app. Para nós, "diferentes lugares" significam diferentes componentes.

No IOWA, criamos shared-app-styles para compartilhar classes de cores, tipografia e layout em páginas e outros componentes que criamos.

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

Como compartilhar o estado do aplicativo

Até agora, você sabe que cada página do nosso app é um elemento personalizado. Já disse isso um milhão de vezes. Ok, mas se cada página for um componente da Web independente, você pode estar se perguntando como compartilhamos o estado no app.

A IOWA usa uma técnica semelhante à injeção de dependência (Angular) ou redux (React) para compartilhar o estado. Criamos uma propriedade app global e penduramos subpropriedades compartilhadas nela. O app é transmitido pelo nosso aplicativo ao ser injetado em todos os componentes que precisam dos dados. Usar os recursos de vinculação de dados do Polymer facilita isso, já que podemos fazer a conexão sem escrever 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 precisa se vincular a app e ler a subpropriedade currentUser. Essa técnica é útil para compartilhar o estado em todo o app. No entanto, outro benefício foi a criação de um elemento de login único e a reutilização dos resultados dele em todo o site. O mesmo vale para as consultas de mídia. Seria um desperdício se cada página duplicasse o login ou criasse seu próprio conjunto de consultas de mídia. Em vez disso, os componentes responsáveis pela funcionalidade/dados do app existem no nível do app.

Transições de página

Ao navegar pelo app da Web do Google I/O, você vai notar as transições de página elegantes (à la design de materiais).

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

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

  1. A navegação de cima desliza uma barra de seleção para o novo link.
  2. O cabeçalho da página desaparece.
  3. O conteúdo da página desliza para baixo e depois desaparece.
  4. Ao reverter essas animações, o título e o conteúdo da nova página aparecem.
  5. (Opcional) A nova página faz mais inicializações.

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

Como funciona

Quando os usuários clicam em uma nova página (ou pressionam "voltar" / "avançar"), o runPageTransition() do roteador faz a mágica executando uma série de promessas. O uso de promessas nos permitiu orquestrar as animações com cuidado e ajudou a racionalizar a "asynchronicidade" das animações CSS e o 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));
    }

}

Lembre-se da seção "Manter as coisas DRY: funcionalidade comum em todas as páginas". As páginas detectam os eventos do DOM page-transition-start e page-transition-done. Agora você sabe onde esses eventos são disparados.

Usamos a API Web Animations em vez dos auxiliares runEnterAnimation/runExitAnimation. No caso de runExitAnimation, pegamos alguns nós DOM (o cabeçalho e a área de conteúdo principal), declaramos o início/fim de cada animação e criamos um GroupEffect para executar as duas 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), 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 do Polymer. Eles oferecem efeitos de rolagem prontos para uso, como navegação superior fixa/retornável, sombras projetadas, 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 na navegação fixa. Como você pode ver no vídeo, ele desaparece quando os usuários rolam a página para baixo e retorna quando rolam para cima.

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

Usamos o elemento <app-header> praticamente como está. Foi fácil inserir e conseguir efeitos de rolagem sofisticados no app. Claro, poderíamos ter implementado esses efeitos, mas ter os detalhes já codificados em um componente reutilizável foi um grande economizador de tempo.

Declare o elemento. Personalize com atributos. Pronto!

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

Conclusão

Para o app da I/O, conseguimos criar um front-end inteiro em várias semanas, graças aos componentes da Web e aos widgets do Material Design pré-criados do Polymer. Os recursos das APIs nativas (elementos personalizados, shadow DOM, <template>) se prestam naturalmente ao dinamismo de um SPA. A reutilização economiza muito tempo.

Se você quiser criar seu próprio Progressive Web App, confira a App Toolbox. A App Toolbox do Polymer é uma coleção de componentes, ferramentas e modelos para criar PWAs com o Polymer. É uma maneira fácil de começar.