Técnicas para carregar um app da Web rapidamente, mesmo em um celular comum

Como usamos a divisão de código, o inline de código e a renderização do lado do servidor no PROXX.

No Google I/O 2019, Mariko, Jake e eu lançamos o PROXX, um clone moderno do Minesweeper para a Web. O que diferencia o PROXX é o foco na acessibilidade (é possível jogar com um leitor de tela!) e a capacidade de ser executado em um feature phone ou em um dispositivo de computador de última geração. Os feature phones são restritos de várias maneiras:

  • CPUs fracas
  • GPUs fracas ou inexistentes
  • Telas pequenas sem entrada por toque
  • Quantidades muito limitadas de memória

Mas eles usam um navegador moderno e são muito acessíveis. Por isso, os feature phones estão ressurgindo nos mercados emergentes. O preço permite que um público totalmente novo, que antes não podia pagar, acesse a Internet e use a Web moderna. A estimativa é de que, em 2019, cerca de 400 milhões de feature phones serão vendidos apenas na Índia. Portanto, os usuários de feature phones podem se tornar uma parte significativa do seu público. Além disso, as velocidades de conexão semelhantes ao 2G são a norma em mercados emergentes. Como conseguimos fazer o PROXX funcionar bem em condições de feature phone?

Jogo do PROXX.

O desempenho é importante, e isso inclui a performance de carregamento e de execução. Foi demonstrado que uma boa performance está relacionada ao aumento da retenção de usuários, das conversões e, mais importante, da inclusão. Jeremy Wagner tem muito mais dados e insights sobre por que a performance é importante.

Esta é a parte 1 de uma série de duas partes. A parte 1 se concentra na performance de carregamento, e a parte 2 se concentra na performance de execução.

Como capturar o status quo

Testar a performance de carregamento em um dispositivo real é fundamental. Se você não tiver um dispositivo real, recomendo o WebPageTest, especificamente a configuração "simples". O WPT executa uma bateria de testes de carregamento em um dispositivo real com uma conexão 3G emulada.

A velocidade de 3G é uma boa opção para medir. Você pode estar acostumado com 4G, LTE ou, em breve, 5G, mas a realidade da Internet móvel é bem diferente. Talvez você esteja em um trem, em uma conferência, em um show ou em um voo. A experiência provavelmente será mais próxima do 3G e, às vezes, até pior.

Dito isso, vamos nos concentrar na rede 2G neste artigo porque a PROXX está segmentando explicitamente feature phones e mercados emergentes no público-alvo. Depois que o WebPageTest executa o teste, você recebe uma queda d'água (semelhante ao que aparece no DevTools) e uma tira de filme na parte de cima. A tira de filme mostra o que o usuário vê enquanto o app está sendo carregado. Em 2G, a experiência de carregamento da versão não otimizada do PROXX é muito ruim:

Esse vídeo mostra o que o usuário vê quando o PROXX está sendo carregado em um dispositivo real de baixo custo com uma conexão 2G emulada.

Quando carregado por 3G, o usuário vê 4 segundos de tela branca. Em redes 2G, o usuário não vê absolutamente nada por mais de 8 segundos. Se você leu por que a performance é importante, sabe que perdemos uma boa parte dos nossos usuários em potencial devido à impaciência. O usuário precisa fazer o download de todos os 62 KB de JavaScript para que algo apareça na tela. O lado positivo é que, no segundo em que algo aparece na tela, ele também é interativo. Ou será que não?

A [First Meaningful Paint][FMP] na versão não otimizada do PROXX é _tecnicamente_ [interativa][TTI], mas inútil para o usuário.

Depois que cerca de 62 KB de JS compactado por gzip são transferidos por download e o DOM é gerado, o usuário pode acessar o app. Ele é tecnicamente interativo. No entanto, o visual mostra uma realidade diferente. As fontes da Web ainda estão sendo carregadas em segundo plano e, até que estejam prontas, o usuário não consegue ver o texto. Embora esse estado se qualifique como um First Meaningful Paint (FMP), ele não se qualifica como interativo, já que o usuário não consegue saber sobre o que são as entradas. O app leva mais um segundo em 3G e três segundos em 2G até ficar pronto para uso. No total, o app leva 6 segundos em 3G e 11 segundos em 2G para ficar interativo.

Análise em hierarquia

Agora que sabemos o que o usuário vê, precisamos descobrir o porquê. Para isso, podemos analisar a hierarquia e descobrir por que os recursos estão carregando com atraso. No nosso rastreamento 2G para PROXX, há duas principais bandeiras vermelhas:

  1. Há várias linhas finas de várias cores.
  2. Os arquivos JavaScript formam uma cadeia. Por exemplo, o segundo recurso só começa a ser carregado quando o primeiro é concluído, e o terceiro recurso só começa quando o segundo é concluído.
A hierarquia mostra quais recursos estão sendo carregados, quando e por quanto tempo.

Reduzir a contagem de conexões

Cada linha fina (dns, connect, ssl) representa a criação de uma nova conexão HTTP. Configurar uma nova conexão é caro, porque leva cerca de 1 segundo em 3G e cerca de 2,5 segundos em 2G. Na nossa hierarquia, há uma nova conexão para:

  • Solicitação 1: index.html
  • Solicitação 5: os estilos de fonte de fonts.googleapis.com
  • Solicitação 8: Google Analytics
  • Solicitação 9: um arquivo de fonte de fonts.gstatic.com
  • Solicitação 14: o manifesto do app da Web

A nova conexão para index.html é inevitável. O navegador precisa criar uma conexão com nosso servidor para acessar o conteúdo. A nova conexão com o Google Analytics poderia ser evitada inserindo algo como o Analytics mínimo, mas o Google Analytics não está impedindo a renderização ou a interação do nosso app. Portanto, não nos importamos com a velocidade de carregamento. O ideal é que o Google Analytics seja carregado no tempo de inatividade, quando tudo já estiver carregado. Dessa forma, ele não ocupa largura de banda nem capacidade de processamento durante a carga inicial. A nova conexão para o manifesto do app da Web é precedida pela especificação de busca, já que o manifesto precisa ser carregado por uma conexão sem credenciais. Novamente, o manifesto do app da Web não impede a renderização ou a interação do app, então não precisamos nos preocupar tanto.

No entanto, as duas fontes e os estilos delas são um problema, porque bloqueiam a renderização e a interatividade. Se olharmos o CSS entregue por fonts.googleapis.com, são apenas duas regras @font-face, uma para cada fonte. Os estilos da fonte são tão pequenos que decidimos inline no nosso HTML, removendo uma conexão desnecessária. Para evitar o custo da configuração de conexão dos arquivos de fontes, podemos copiá-los para nosso próprio servidor.

Como carregar em paralelo

Analisando o gráfico em cascata, podemos ver que, quando o primeiro arquivo JavaScript é carregado, novos arquivos começam a ser carregados imediatamente. Isso é típico de dependências de módulo. Nosso módulo principal provavelmente tem importações estáticas, então o JavaScript não pode ser executado até que essas importações sejam carregadas. O importante é saber que esses tipos de dependências são conhecidos no momento da criação. Podemos usar as tags <link rel="preload"> para garantir que todas as dependências comecem a carregar assim que recebermos o HTML.

Resultados

Vamos conferir o que nossas mudanças alcançaram. É importante não mudar nenhuma outra variável na configuração do teste que possa distorcer os resultados. Portanto, vamos usar a configuração simples do WebPageTest para o restante deste artigo e analisar a tira de filme:

Usamos a tira de filme do WebPageTest para conferir os resultados das nossas mudanças.

Essas mudanças reduziram nosso TTI de 11 para 8,5, que é aproximadamente o tempo de configuração de conexão de 2,5 segundos que queríamos remover. Parabéns a nós.

Pré-renderização

Embora tenhamos reduzido o TTI, não afetamos a tela branca eterna que o usuário precisa suportar por 8,5 segundos. É possível que as maiores melhorias para FMP sejam alcançadas enviando marcações estilizadas no index.html. Técnicas comuns para fazer isso são a pré-renderização e a renderização do lado do servidor, que estão intimamente relacionadas e são explicadas em Renderização na Web. Ambas as técnicas executam o app da Web no Node e serializam o DOM resultante em HTML. A renderização do lado do servidor faz isso por solicitação no lado do servidor, enquanto a pré-renderização faz isso no momento da criação e armazena a saída como seu novo index.html. Como o PROXX é um app JAMStack e não tem lado do servidor, decidimos implementar a pré-renderização.

Há muitas maneiras de implementar um pré-renderizador. No PROXX, escolhemos usar o Puppeteer, que inicia o Chrome sem nenhuma interface e permite controlar remotamente essa instância com uma API do Node. Usamos isso para injetar nossa marcação e nosso JavaScript e, em seguida, ler o DOM como uma string de HTML. Como estamos usando Módulos CSS, temos o inline CSS dos estilos necessários sem custo financeiro.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Com isso, esperamos uma melhoria no FMP. Ainda precisamos carregar e executar a mesma quantidade de JavaScript que antes, então não esperamos que o TTI mude muito. O index.html aumentou e pode atrasar um pouco o TTI. Só há uma maneira de descobrir: executando o WebPageTest.

Essa tira de filme mostra uma melhoria clara na métrica de FMP. O TTI não é afetado.

Nossa First Meaningful Paint passou de 8,5 segundos para 4,9 segundos, uma melhoria enorme. Nosso TTI ainda acontece em cerca de 8,5 segundos, então não foi muito afetado por essa mudança. O que fizemos aqui foi uma mudança perceptual. Alguns até chamam isso de truque. Ao renderizar um visual intermediário do jogo, estamos melhorando a percepção do desempenho de carregamento.

Incorporação

Outra métrica que tanto o DevTools quanto o WebPageTest fornecem é o tempo até o primeiro byte (TTFB). É o tempo que leva do primeiro byte da solicitação enviada ao primeiro byte da resposta recebida. Esse tempo também é chamado de tempo de ida e volta (RTT, na sigla em inglês), embora tecnicamente haja uma diferença entre esses dois números: o RTT não inclui o tempo de processamento da solicitação no servidor. O DevTools e o WebPageTest mostram o TTFB com uma cor clara no bloco de solicitação/resposta.

A seção clara de uma solicitação indica que ela está aguardando o primeiro byte da resposta.

Analisando nossa hierarquia, podemos ver que todas as solicitações passam a maior parte do tempo esperando a chegada do primeiro byte da resposta.

Esse problema foi o motivo pelo qual o push de HTTP/2 foi concebido. O desenvolvedor do app sabe que alguns recursos são necessários e pode enviar esses recursos. Quando o cliente percebe que precisa buscar mais recursos, eles já estão nos caches do navegador. O push de HTTP/2 se mostrou muito difícil de configurar corretamente e não é mais recomendado. Esse espaço de problema será revisitado durante a padronização do HTTP/3. Por enquanto, a solução mais fácil é inlinear todos os recursos críticos em detrimento da eficiência do armazenamento em cache.

Nosso CSS crítico já está inline graças aos módulos CSS e ao nosso prerenderizador baseado no Puppeteer. Para JavaScript, precisamos incluir nossos módulos essenciais e as dependências deles. A dificuldade dessa tarefa varia de acordo com o bundler que você está usando.

Com a inserção do JavaScript, reduzimos o TTI de 8,5 segundos para 7,2 segundos.

Isso reduziu um segundo do nosso TTI. Agora chegamos ao ponto em que o index.html contém tudo o que é necessário para a renderização inicial e a interação. O HTML pode ser renderizado enquanto ainda está sendo transferido, criando o FMP. Assim que o HTML terminar de analisar e executar, o app vai ficar interativo.

Divisão agressiva do código

Sim, nosso index.html contém tudo o que é necessário para se tornar interativo. Mas, em uma inspeção mais detalhada, descobrimos que ele também contém tudo o mais. Nosso index.html tem cerca de 43 KB. Vamos relacionar isso ao que o usuário pode interagir no início: temos um formulário para configurar o jogo que contém alguns componentes, um botão de início e provavelmente algum código para manter e carregar as configurações do usuário. É isso. 43 KB parece muito.

A página de destino do PROXX. Somente componentes críticos são usados aqui.

Para entender de onde vem o tamanho do pacote, podemos usar um source map explorer ou uma ferramenta semelhante para detalhar o que o pacote contém. Como previsto, nosso pacote contém a lógica do jogo, o mecanismo de renderização, a tela de vitória, a tela de derrota e vários utilitários. Apenas um pequeno subconjunto desses módulos é necessário para a página de destino. Mover tudo o que não é estritamente necessário para a interatividade para um módulo carregado de forma lenta vai diminuir o TTI significativamente.

Analisando o conteúdo de "index.html" do PROXX, muitos recursos desnecessários são mostrados. Os recursos críticos são destacados.

O que precisamos fazer é dividir o código. A divisão de código separa seu pacote monolítico em partes menores que podem ser carregadas de forma lenta sob demanda. Os bundlers mais conhecidos, como o Webpack, o Rollup e o Parcel, oferecem suporte à divisão de código usando import() dinâmico. O bundler vai analisar seu código e inline todos os módulos importados staticamente. Tudo o que você importar dinamicamente será colocado no próprio arquivo e só será buscado da rede depois que a chamada import() for executada. É claro que acessar a rede tem um custo e só deve ser feito se você tiver tempo disponível. O mantra aqui é importar de forma estática os módulos que são essenciais no momento do carregamento e carregar tudo o mais de forma dinâmica. No entanto, não espere até o último momento para carregar de forma lazy os módulos que serão definitivamente usados. O Idle Until Urgent de Phil Walton é um ótimo padrão para um meio-termo saudável entre o carregamento lento e o carregamento imediato.

No PROXX, criamos um arquivo lazy.js que importa estaticamente tudo o que não precisamos. No arquivo principal, podemos importar lazy.js dinamicamente. No entanto, alguns dos nossos componentes do Preact acabaram em lazy.js, o que acabou sendo um pouco complicado, já que o Preact não pode processar componentes carregados com carregamento lento. Por esse motivo, criamos um pequeno wrapper de componente deferred que permite renderizar um marcador de posição até que o componente real seja carregado.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Com isso, podemos usar uma promessa de um componente nas nossas funções render(). Por exemplo, o componente <Nebula>, que renderiza a imagem de plano de fundo animada, será substituído por um <div> vazio enquanto o componente estiver sendo carregado. Quando o componente for carregado e pronto para uso, o <div> será substituído pelo componente real.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Com tudo isso em vigor, reduzimos o index.html para apenas 20 KB, menos da metade do tamanho original. Qual é o efeito disso na FMP e no TTI? O WebPageTest vai dizer!

As miniaturas de fotos confirmam: nosso TTI agora está em 5,4 segundos. Uma melhoria drástica em relação ao iPhone 11 original.

Nossas FMPs e TTIs estão separadas por apenas 100 ms, porque é apenas uma questão de analisar e executar o JavaScript inline. Depois de apenas 5,4 segundos em 2G, o app fica totalmente interativo. Todos os outros módulos menos essenciais são carregados em segundo plano.

Mais truques de mão

Se você olhar a lista de módulos essenciais acima, vai notar que o mecanismo de renderização não faz parte deles. É claro que o jogo não pode ser iniciado até que tenhamos um mecanismo de renderização para renderizar o jogo. Poderíamos desativar o botão "Start" até que o mecanismo de renderização estivesse pronto para iniciar o jogo, mas, na nossa experiência, o usuário geralmente leva tempo suficiente para configurar as configurações do jogo, e isso não é necessário. Na maioria das vezes, o mecanismo de renderização e os outros módulos restantes são carregados quando o usuário pressiona "Start". No raro caso em que o usuário é mais rápido que a conexão de rede, mostramos uma tela de carregamento simples que aguarda a conclusão dos módulos restantes.

Conclusão

A medição é importante. Para evitar perder tempo com problemas que não são reais, recomendamos que você sempre meça antes de implementar otimizações. Além disso, as medições devem ser feitas em dispositivos reais com uma conexão 3G ou no WebPageTest se não houver um dispositivo real disponível.

A tira de filme pode dar insights sobre como o carregamento do app parece para o usuário. A hierarquia mostra quais recursos são responsáveis por tempos de carregamento potencialmente longos. Confira uma lista de verificação com ações para melhorar a performance de carregamento:

  • Transmita o máximo de recursos possível em uma conexão.
  • Carregue ou até mesmo recursos inline que são necessários para a primeira renderização e interatividade.
  • Pré-renderizar o app para melhorar a percepção do desempenho de carregamento.
  • Use a divisão de código agressiva para reduzir a quantidade de código necessária para a interatividade.

Fique ligado na parte 2, em que vamos discutir como otimizar o desempenho do ambiente de execução em dispositivos com restrições extremas.