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

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

No Google I/O 2019, Mariko, Jake e eu enviamos o PROXX, um clone de campo minado moderno para a Web. Algo que diferencia o PROXX é o foco na acessibilidade (você pode reproduzi-lo com um leitor de tela) e a capacidade de executá-lo em um feature phone ou em um dispositivo desktop de última geração. Os feature phone são limitados de várias maneiras:

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

Mas eles executam um navegador moderno e são muito acessíveis. Por esse motivo, os telefones básicos estão ressurgindo em mercados emergentes. A faixa de preços deles permite que um público totalmente novo, que antes não podia pagar por isso, acesse a Internet e faça uso da Web moderna. Para 2019, estima-se que cerca de 400 milhões de telefones básicos sejam vendidos apenas na Índia. Portanto, os usuários desse tipo de smartphone podem se tornar uma parte significativa do seu público. Além disso, velocidades de conexão semelhantes ao 2G são a norma em mercados emergentes. Como conseguimos fazer o PROXX funcionar bem nas condições de feature phone?

PROXX.

O desempenho é importante e inclui tanto o desempenho de carregamento quanto o de tempo de execução. Foi demonstrado que um bom desempenho se correlaciona com o aumento da retenção de usuários, o aumento das conversões e, o mais importante, o aumento da inclusão. Jeremy Wagner tem muito mais dados e insights sobre por que o desempenho é importante.

Esta é a primeira parte de uma série. A parte 1 se concentra no desempenho de carregamento e a parte 2 se concentra no desempenho em tempo de execução.

Como conseguir o status quo

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

3G é uma boa velocidade para medir. Embora você esteja acostumado com 4G, LTE ou, em breve, até 5G, a realidade da Internet para dispositivos móveis é bem diferente. Talvez você esteja em um trem, em uma conferência, em um show ou em um voo. Sua experiência provavelmente está mais próxima do 3G e, às vezes, pior.

Dito isso, vamos nos concentrar no 2G neste artigo porque a PROXX está explicitamente segmentando telefones básicos e mercados emergentes em seu público-alvo. Depois que o WebPageTest executar o teste, você receberá uma cascata (semelhante ao que você vê no DevTools), bem como uma tira de filme na parte superior. 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:

O vídeo em tira de filme mostra o que o usuário vê quando o PROXX está carregando em um dispositivo real de baixo custo por uma conexão 2G emulada.

Quando carregado em 3G, o usuário vê um nada branco por quatro segundos. Em 2G, o usuário não vê nada por mais de oito segundos. Se você ler por que o desempenho é importante, vai saber que perdemos uma boa parte dos usuários em potencial por impaciência. O usuário precisa baixar todos os 62 KB de JavaScript para que algo apareça na tela. O lado bom desse cenário é que o segundo elemento que aparece na tela também é interativo. Ou será que não?

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

Após cerca de 62 KB de JS em gzip serem baixados e o DOM ser gerado, o usuário poderá ver nosso aplicativo. O app é tecnicamente interativo. Contudo, observar 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 verá o texto. Embora esse estado seja qualificado como uma Primeira pintura significativa (FMP, na sigla em inglês), ele certamente não se qualifica como interativo de maneira adequada, porque o usuário não consegue saber sobre o que é uma das entradas. Leva mais um segundo em conexões 3G e três segundos em 2G até que o app esteja pronto. Resumindo, o app leva 6 segundos em conexões 3G e 11 segundos em 2G para se tornar interativo.

Análise de cascata

Agora que sabemos o que o usuário vê, precisamos descobrir o porquê. Para isso, podemos observar a hierarquia e analisar por que os recursos estão sendo carregados tarde demais. Em nosso rastreamento 2G para PROXX, podemos ver dois principais sinais de alerta:

  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 só começa quando o segundo é concluído.
.
Ela fornece insights sobre quais recursos estão sendo carregados, quando e quanto tempo eles levam.

Como reduzir a contagem de conexões

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

  • Solicitação no 1: nossa index.html
  • Solicitação no 5: os estilos de fonte de fonts.googleapis.com
  • Solicitação no 8: Google Analytics
  • Solicitação no 9: um arquivo de fonte de fonts.gstatic.com
  • Solicitação no 14: o manifesto de 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. Para evitar a nova conexão com o Google Analytics, crie uma linha com algo como o Minimal Analytics, mas o Google Analytics não impede que nosso app seja renderizado ou se torne interativo. Por isso, não nos preocupamos com a velocidade de carregamento dele. O ideal é que o Google Analytics seja carregado em tempo ocioso, quando todo o restante já foi carregado. Dessa forma, ela não consumirá largura de banda nem capacidade de processamento durante a carga inicial. A nova conexão para o manifesto de app da Web é prescrita pela especificação de busca (link em inglês), porque o manifesto precisa ser carregado por uma conexão não credenciada. Novamente, o manifesto do app da Web não bloqueia a renderização ou a interação do app, então não precisamos nos preocupar muito.

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

Carregamento em paralelo de cargas

Analisando a hierarquia, vemos que, assim que o primeiro arquivo JavaScript é carregado, os novos arquivos começam a ser carregados imediatamente. Isso é comum para dependências de módulos. Provavelmente, nosso módulo principal tem importações estáticas, então o JavaScript não pode ser executado até que essas importações sejam carregadas. O importante a ser percebido aqui é que esses tipos de dependências são conhecidos no tempo de compilação. Podemos usar tags <link rel="preload"> para garantir que todas as dependências comecem a carregar assim que recebermos o HTML.

Resultados

Vamos analisar os resultados das nossas mudanças. É importante não alterar nenhuma outra variável na configuração do teste que possa distorcer os resultados. Por isso, usaremos a configuração simples do WebPageTest para o restante deste artigo e observaremos a tira de filme:

Usamos a tira de vídeo do WebPageTest para ver o resultado das nossas alterações.

Essas mudanças reduziram o TTI de 11 para 8,5, que é aproximadamente 2,5 segundos de tempo de configuração da conexão que pretendemos remover. Parabéns.

Pré-renderização

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

Há muitas maneiras de implementar um pré-renderizador. Na 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, lemos o DOM como uma string de HTML. Como estamos usando os módulos CSS, temos os CSS inline 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 que a nossa FMP melhore. Ainda precisamos carregar e executar a mesma quantidade de JavaScript de antes, então não devemos esperar que o TTI mude muito. Na verdade, nosso index.html ficou maior e pode atrasar um pouco o TTI. Há apenas uma maneira de descobrir: executando o WebPageTest.

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

Nossa primeira pintura significativa mudou de 8,5 segundos para 4,9 segundos, uma grande melhoria. O TTI ainda acontece em torno de 8,5 segundos, então não foi afetado por essa mudança. O que fizemos aqui foi uma mudança perceptiva. Alguns podem até chamar isso de trenó. Ao renderizar um visual intermediário do jogo, melhoramos a percepção do desempenho de carregamento.

In-line

Outra métrica fornecida pelo DevTools e pelo WebPageTest é o tempo para o primeiro byte (TTFB, na sigla em inglês). É o tempo decorrido entre o primeiro byte da solicitação enviado e o primeiro byte da resposta recebida. Esse tempo também é chamado de RTT (tempo de retorno), embora tecnicamente exista uma diferença entre esses dois números: o RTT não inclui o tempo de processamento da solicitação no lado do servidor. O DevTools e o WebPageTest exibem o TTFB com uma cor clara no bloco de solicitação/resposta.

A seção light de uma solicitação significa que a solicitação está aguardando para receber o primeiro byte da resposta.

Analisando nossa hierarquia, podemos notar que todas as solicitações passam a maior parte do tempo aguardando o primeiro byte da resposta.

Originalmente, o push HTTP/2 era projetado para esse problema. O desenvolvedor de apps sabe que determinados recursos são necessários e pode impulsioná-los. Quando o cliente percebe que precisa buscar recursos adicionais, eles já estão nos caches do navegador. O envio push do HTTP/2 acabou sendo muito difícil de acertar e é considerado desencorajado. Esse espaço de problema será revisto durante a padronização do HTTP/3. Por enquanto, a solução mais fácil é in-line todos os recursos essenciais em detrimento da eficiência do armazenamento em cache.

Nosso CSS essencial já está embutido graças aos módulos CSS e ao pré-renderizador baseado no Puppeteer. Para JavaScript, precisamos alinhar nossos módulos essenciais e as dependências deles. Esta tarefa tem diferentes dificuldades de acordo com o bundler que você está usando.

.
Com o in-line do nosso JavaScript, reduzimos o TTI de 8,5 s para 7,2 s.

Isso eliminou um segundo do nosso TTI. Chegamos ao ponto em que index.html contém tudo o que é necessário para a renderização inicial e a interação. O HTML pode ser renderizado durante o download, criando nossa FMP. No momento em que o HTML termina de analisar e executar, o aplicativo se torna interativo.

Divisão agressiva de código

Sim, nossa index.html contém tudo o que é necessário para se tornar interativo. Mas, ao observar mais de perto, constatamos que ele também contém todo o resto. O index.html tem cerca de 43 KB. Vamos colocar isso em relação 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 "Iniciar" e provavelmente algum código para persistir e carregar as configurações do usuário. É basicamente isso. 43 KB parece muito.

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

Para entender de onde vem o tamanho do nosso pacote, podemos usar um explorador de mapa de origem ou uma ferramenta semelhante para detalhar no que o pacote é composto. Como previsto, nosso pacote contém a lógica do jogo, o mecanismo de renderização, a tela de vitória, a de perda e vários utilitários. É necessário apenas um pequeno subconjunto desses módulos para a página de destino. Mover tudo o que não é estritamente necessário para a interatividade para um módulo com carregamento lento diminui o TTI significativamente.

A análise do conteúdo de `index.html` do PROXX mostra muitos recursos desnecessários. Os recursos críticos estão destacados.

O que precisamos fazer é dividir o código. A divisão de código divide o pacote monolítico em partes menores que podem ser carregadas lentamente sob demanda. Bundlers conhecidos, como Webpack, Rollup e Parcel, são compatíveis com a divisão de código usando import() dinâmico. O bundler analisará seu código e vai inline de todos os módulos importados estaticamente. Tudo o que você importar dinamicamente será colocado em um arquivo próprio e só será buscado na 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 de sobra. O lema é importar estaticamente os módulos que são criticamente necessários no tempo de carregamento e carregar o restante dinamicamente. Mas não espere até o último momento para carregar lentamente os módulos que vão ser usados com certeza. O livro Idle até Urgent (em inglês) de Phil Walton é um ótimo padrão para um meio-termo saudável entre o carregamento lento e o rápido.

Na PROXX, criamos um arquivo lazy.js que importa estaticamente tudo o que não é necessário. No arquivo principal, podemos importar lazy.js dinamicamente. No entanto, alguns dos componentes do Preact acabaram em lazy.js, o que acabou sendo um pouco complicado, já que o Preact não consegue processar componentes carregados lentamente por padrão. Por esse motivo, criamos um pequeno wrapper 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 em vigor, podemos usar uma promessa de um componente em 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. Depois que o componente estiver 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 a apenas 20 KB, menos da metade do tamanho original. Qual é o efeito disso na FMP e no TTI? o WebPageTest vai informar.

A tira de filme confirma: nosso TTI agora está em 5,4 segundos. Uma melhoria drástica em relação aos 11s originais.

Nossa FMP e TTI estão a apenas 100 ms de distância, uma vez que é apenas uma questão de analisar e executar o JavaScript embutido. Depois de apenas 5,4 segundos em 2G, o app é completamente interativo. Todos os outros módulos menos essenciais são carregados em segundo plano.

Mais trenó

Se você consultar nossa lista de módulos críticos acima, vai perceber que o mecanismo de renderização não faz parte dos módulos essenciais. É claro que o jogo não poderá ser iniciado até que tenhamos nosso mecanismo de renderização para renderizá-lo. Podemos desativar a opção "Iniciar" até que o mecanismo de renderização esteja pronto para iniciar o jogo. No entanto, pela nossa experiência, o usuário normalmente leva tempo suficiente para definir as configurações do jogo, o que não é necessário. Na maioria das vezes, o carregamento do mecanismo de renderização e dos demais módulos é concluído no momento em que o usuário pressiona "Start". No caso raro de o usuário ser mais rápido do que a conexão de rede, mostramos uma tela de carregamento simples que aguarda a conclusão dos módulos restantes.

Conclusão

Medir é importante. Para não perder tempo com problemas que não são reais, recomendamos sempre medir antes de implementar otimizações. Além disso, as medições devem ser feitas em dispositivos reais com conexão 3G ou no WebPageTest caso nenhum dispositivo real esteja à mão.

A tira de filme pode fornecer insights sobre a sensação do usuário ao carregar o app. Ela pode informar quais recursos são responsáveis por tempos de carregamento possivelmente longos. Aqui está uma lista de verificação do que você pode fazer para melhorar o desempenho de carregamento:

  • Envie o maior número possível de recursos em uma conexão.
  • Pré-carregue ou até mesmo recursos inline que são necessários para a primeira renderização e interatividade.
  • Pré-renderize seu app para melhorar o desempenho de carregamento percebido.
  • Use a divisão de código agressiva para reduzir a quantidade de código necessária para interatividade.

Continue acompanhando a parte 2, em que discutimos como otimizar o desempenho do tempo de execução em dispositivos hiper-restritos.