Técnicas para fazer um app da Web carregar rapidamente, mesmo em um feature phone

Como usamos a divisão de código, o inlining 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 Campo Minado para a Web. O que diferencia o PROXX é o foco na acessibilidade (você pode jogar com um leitor de tela!) e a capacidade de funcionar tão bem em um telefone básico quanto em um dispositivo desktop sofisticado. Os telefones básicos têm várias limitações:

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

Mas eles executam um navegador mais recente e são muito acessíveis. Por isso, os telefones básicos estão ressurgindo nos mercados emergentes. A faixa de preços permite que um público totalmente novo, que antes não tinha condições financeiras, acesse a Internet e use a Web moderna. Para 2019, a projeção é de que cerca de 400 milhões de telefones básicos sejam vendidos apenas na Índia. Portanto, os usuários desses dispositivos podem se tornar uma parte significativa do seu público-alvo. Além disso, velocidades de conexão semelhantes ao 2G são comuns em mercados emergentes. Como conseguimos fazer o PROXX funcionar bem em condições de telefone básico?

Gameplay do PROXX.

A performance é importante, e isso inclui tanto a performance de carregamento quanto a de execução. Foi demonstrado que um bom desempenho está correlacionado com o aumento da retenção de usuários, melhores conversões e, o mais importante, maior inclusão. Jeremy Wagner tem muito mais dados e insights sobre por que a performance é importante.

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

Captura do status quo

É fundamental testar o desempenho de carregamento em um dispositivo real. Se você não tiver um dispositivo real em mãos, recomendamos 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.

O 3G é uma boa velocidade para medir. Embora você esteja acostumado com 4G, LTE ou, em breve, até mesmo 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. O que você vai encontrar lá é provavelmente mais próximo do 3G e, às vezes, até pior.

Dito isso, vamos nos concentrar no 2G neste artigo porque o PROXX tem como público-alvo os telefones básicos e os mercados emergentes. Depois que o WebPageTest executar o teste, você vai receber um gráfico em cascata (semelhante ao que aparece no DevTools) e uma miniatura na parte de cima. A tira de filme mostra o que o usuário vê enquanto o app está carregando. Em 2G, a experiência de carregamento da versão não otimizada do PROXX é muito ruim:

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

Quando carregado em 3G, o usuário vê 4 segundos de uma tela branca. Em uma conexão 2G, o usuário não vê absolutamente nada por mais de 8 segundos. Se você leu por que o desempenho é importante, sabe que perdemos uma boa parte dos nossos usuários em potencial devido à impaciência. O usuário precisa baixar todos os 62 KB de JavaScript para que algo apareça na tela. O lado positivo desse cenário é que, assim que algo aparece na tela, já é possível interagir com ele. 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 com gzip forem baixados e o DOM for gerado, o usuário poderá ver nosso app. O app é 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 pode ver nenhum texto. Embora esse estado se qualifique como uma First Meaningful Paint (FMP), ele certamente não se qualifica como interativo, já que o usuário não consegue saber do que se trata cada uma das entradas. Leva mais um segundo no 3G e três segundos no 2G até que o app esteja pronto para uso. No total, o app leva seis segundos no 3G e 11 segundos no 2G para se tornar interativo.

Análise em hierarquia

Agora que sabemos o que o usuário vê, precisamos descobrir o porquê. Para isso, podemos analisar o gráfico em cascata e entender por que os recursos estão carregando tarde demais. No trace de 2G do PROXX, podemos ver dois grandes sinais de alerta:

  1. Há várias linhas finas e multicoloridas.
  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.
O gráfico em cascata mostra quais recursos estão sendo carregados, quando e quanto tempo eles levam.

Reduzindo o número 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, já que leva cerca de 1s em 3G e aproximadamente 2,5s em 2G. Na nossa hierarquia, vemos uma nova conexão para:

  • Pedido nº 1: nosso index.html
  • Solicitação nº 5: os estilos de fonte de fonts.googleapis.com
  • Solicitação nº 8: Google Analytics
  • Solicitação nº 9: um arquivo de fonte de fonts.gstatic.com
  • Solicitação nº 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 receber o conteúdo. A nova conexão para o Google Analytics poderia ser evitada ao incorporar algo como o Minimal Analytics, mas o Google Analytics não está impedindo que nosso app seja renderizado ou se torne interativo. Portanto, não nos importamos com a velocidade de carregamento. O ideal é que o Google Analytics seja carregado no tempo ocioso, quando tudo já tiver sido carregado. Dessa forma, ele não vai ocupar largura de banda ou capacidade de processamento durante o carregamento inicial. A nova conexão para o manifesto do app da Web é prescrita 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 que o app seja renderizado ou se torne interativo, 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 analisarmos o CSS fornecido por fonts.googleapis.com, veremos que ele tem apenas duas regras @font-face, uma para cada fonte. Os estilos de fonte são tão pequenos que decidimos incorporá-los ao HTML, removendo uma conexão desnecessária. Para evitar o custo da configuração de conexão para os arquivos de fonte, podemos copiá-los para nosso próprio servidor.

Carregamentos em paralelo

Analisando o gráfico em cascata, podemos ver que, assim que o primeiro arquivo JavaScript é carregado, novos arquivos começam a ser carregados imediatamente. Isso é comum para 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 é perceber que esses tipos de dependências são conhecidos no momento da criação. Podemos usar tags <link rel="preload"> para garantir que todas as dependências comecem a ser carregadas no momento em que recebemos 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. Por isso, vamos usar a configuração simples do WebPageTest no restante deste artigo e analisar a miniatura:

Usamos a miniatura do WebPageTest para ver o que nossas mudanças alcançaram.

Essas mudanças reduziram nosso TTI de 11 para 8,5, que é aproximadamente os 2,5 segundos de tempo de configuração da conexão que queríamos remover. Arrasamos!

Pré-renderização

Embora tenhamos reduzido o TTI, não afetamos a tela branca que o usuário precisa aguentar por 8,5 segundos. As maiores melhorias para a FMP podem ser alcançadas enviando marcações estilizadas no seu index.html. As 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. As duas 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 um 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 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, recebemos o 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, podemos esperar uma melhoria no FMP. Ainda precisamos carregar e executar a mesma quantidade de JavaScript que antes, então não devemos esperar que o TTI mude muito. Na verdade, nosso index.html aumentou e pode atrasar um pouco o TTI. Só há uma maneira de descobrir: executando o WebPageTest.

A miniatura mostra uma melhoria clara na métrica FMP. O TTI não é afetado.

Nossa First Meaningful Paint passou de 8,5 para 4,9 segundos, uma melhoria enorme. O 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 perceptiva. Alguns podem até chamar isso de truque de mágica. Ao renderizar um visual intermediário do jogo, melhoramos a performance de carregamento percebida.

Inlining

Outra métrica que o DevTools e o WebPageTest fornecem é o Time To First 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 retorno (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 lado do 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 recebimento do primeiro byte da resposta.

Analisando nosso gráfico em cascata, podemos ver que todas as solicitações passam a maior parte do tempo esperando que o primeiro byte da resposta chegue.

Esse problema foi o motivo pelo qual o HTTP/2 Push foi originalmente concebido. O desenvolvedor de apps sabe que determinados recursos são necessários e pode enviá-los pela rede. Quando o cliente percebe que precisa buscar mais recursos, eles já estão nos caches do navegador. O HTTP/2 Push acabou sendo muito difícil de acertar e é considerado desencorajado. Esse problema será revisitado durante a padronização do HTTP/3. Por enquanto, a solução mais fácil é incorporar 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 pré-renderizador baseado em Puppeteer. Para JavaScript, precisamos incorporar nossos módulos essenciais e as dependências deles. Essa tarefa tem dificuldade variável, dependendo do bundler que você está usando.

Com o inline do JavaScript, reduzimos o TTI de 8,5s para 7,2s.

Isso reduziu em 1 segundo o TTI. Agora, o index.html contém tudo o que é necessário para a renderização inicial e para se tornar interativo. O HTML pode ser renderizado enquanto ainda está sendo baixado, criando nossa FMP. No momento em que o HTML é analisado e executado, o app se torna interativo.

Divisão de código agressiva

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

A página de destino do PROXX. Somente os componentes essenciais são usados aqui.

Para entender de onde vem o tamanho do pacote, podemos usar um explorador de mapa de origem ou uma ferramenta semelhante para detalhar o que compõe o pacote. 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 de carregamento lento vai diminuir o TTI significativamente.

A análise do conteúdo do arquivo "index.html" do PROXX mostra muitos recursos desnecessários. Os recursos críticos são destacados.

O que precisamos fazer é dividir o código. A divisão de código divide seu pacote monolítico em partes menores que podem ser carregadas lentamente sob demanda. Bundlers conhecidos, como Webpack, Rollup e Parcel, oferecem suporte à divisão de código usando import() dinâmico. O bundler vai analisar seu código e incorporar 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 quando 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 mantra aqui é importar estaticamente os módulos que são essenciais no momento do carregamento e carregar dinamicamente todo o resto. Mas não espere até o último momento para carregar lentamente os módulos que definitivamente serão usados. O padrão Idle Until Urgent (link em inglês) de Phil Walton é um ótimo meio-termo entre o carregamento lento e o carregamento antecipado.

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 no lazy.js, o que acabou sendo um pouco complicado, já que o Preact não consegue lidar com componentes de carregamento lento de forma nativa. Por isso, escrevemos 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 Promise de um componente nas nossas funções render(). Por exemplo, o componente <Nebula>, que renderiza a imagem de fundo animada, será substituído por um <div> vazio enquanto o componente estiver carregando. Quando 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, reduzimos nosso 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.

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

Nossa FMP e TTI estão separadas por apenas 100 ms, já que é 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ágica

Se você consultar nossa lista de módulos críticos acima, vai notar que o mecanismo de renderização não faz parte deles. Claro, o jogo não pode começar até que o mecanismo de renderização esteja pronto. Poderíamos desativar o botão "Iniciar" até que o mecanismo de renderização esteja pronto para iniciar o jogo, mas, pela nossa experiência, o usuário geralmente leva tempo suficiente para definir as configurações do jogo, então isso não é necessário. Na maioria das vezes, o mecanismo de renderização e os outros módulos restantes já estão carregados quando o usuário pressiona "Iniciar". No caso raro de o usuário ser 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 gastar tempo com problemas que não são reais, recomendamos sempre medir antes de implementar otimizações. Além disso, as medições precisam ser feitas em dispositivos reais com uma conexão 3G ou no WebPageTest se não houver um dispositivo real disponível.

A miniatura pode dar uma ideia de como o carregamento do app parece para o usuário. A cascata pode informar quais recursos são responsáveis por tempos de carregamento potencialmente longos. Confira uma lista de verificação de coisas que você pode fazer para melhorar o desempenho de carregamento:

  • Envie o máximo de recursos possível em uma única conexão.
  • Pré-carregue ou até mesmo recursos inline necessários para a primeira renderização e interatividade.
  • Pré-renderize o app para melhorar a performance de carregamento percebida.
  • Use a divisão de código agressiva para reduzir a quantidade de código necessária para a interatividade.

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