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

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

No Google I/O 2019, Mariko, Jake e eu lançamos o PROXX, um moderno clone de campo minado para a Web. O diferencial do PROXX é o foco na acessibilidade (você pode jogar com um leitor de tela) e na capacidade de execução tanto em um feature phone quanto em um computador de última geração. Os telefones básicos são limitados de várias maneiras:

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

Mas eles executam um navegador moderno e são muito acessíveis. Por esse motivo, os telefones básicos estão fazendo um ressurgimento nos mercados emergentes. Seu preço permite que um público totalmente novo, que antes não podia pagar, entra on-line e faz uso da Web moderna. Para 2019, estima-se que cerca de 400 milhões de feature phone serão vendidos apenas na Índia, então os usuários desses telefones podem se tornar uma parte significativa do seu público. Além disso, velocidades de conexão semelhantes ao 2G são o padrão em mercados emergentes. Como fizemos para que o PROXX funcionasse bem em condições de feature phone?

Jogabilidade em PROXX.

O desempenho é importante, e isso inclui tanto o desempenho de carregamento quanto o de execução. Um bom desempenho está relacionado ao aumento da retenção de usuários, do aumento nas conversões e, o mais importante, do aumento da inclusão. Jeremy Wagner tem muito mais dados e insights sobre por que a performance é 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 do tempo de execução.

Captura do status quo

Testar o desempenho de carregamento em um dispositivo real é fundamental. Se você não tem um dispositivo real disponível, 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. Talvez você já esteja acostumado com o 4G, LTE ou, em breve, com o 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. Você provavelmente se aproxima do 3G e, às vezes, pior ainda.

Dito isso, vamos nos concentrar no 2G neste artigo porque o PROXX está explicitamente segmentando telefones de recurso e mercados emergentes em seu público-alvo. Depois que o WebPageTest executar o teste, você terá uma cascata (semelhante ao que você encontra no DevTools), além de uma tira de filme na parte de cima. A tira de filme mostra o que aparece para o usuário enquanto o app é carregado. Em 2G, a experiência de carregamento da versão não otimizada do PROXX é muito ruim:

O vídeo de tira de filme mostra o que o usuário vê quando o PROXX é carregado em um dispositivo real e mais simples por meio de uma conexão 2G emulada.

Quando carregado por 3G, o usuário vê 4 segundos de nada branco. Com mais de 2G, o usuário não vê absolutamente nada por mais de 8 segundos. Se você leu por que o desempenho é importante, sabe que agora perdemos 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 bom nesse cenário é que a segunda coisa que aparece na tela também é interativa. Ou será que não?

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

Depois do download de cerca de 62 KB de JS gzip e a geração do DOM feita, o usuário pode ver nosso app. O aplicativo é tecnicamente interativo. No entanto, 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 vê texto. Embora esse estado seja qualificado como primeira exibição significativa (FMP, na sigla em inglês), ele certamente não se qualifica como interativo, já que o usuário não sabe sobre o que é nenhuma das entradas. Leva mais um segundo no 3G e três segundos no 2G até que o aplicativo esteja pronto. Resumindo, o aplicativo leva 6 segundos no 3G e 11 segundos no 2G para se tornar interativo.

Análise em 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 com atraso. Em nosso rastreamento 2G para 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 carregamento do segundo recurso começa quando o primeiro é concluído, e o terceiro só começa quando o segundo é concluído.
A hierarquia oferece insights sobre quais recursos estão sendo carregados, quando e em quanto tempo.

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

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

A nova conexão com 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 pode ser evitada com a inserção de elementos como Mínimo Analytics, mas o Google Analytics não está impedindo que o app seja renderizado ou se torne interativo. Portanto, a velocidade de carregamento não é muito importante. O ideal é que o Google Analytics seja carregado em tempo ocioso, quando tudo o mais já tiver sido carregado. Dessa forma, ele não consumirá largura de banda nem 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 não credenciada. Novamente, o manifesto do app da Web não impede que o app seja renderizado ou se torne interativo. Portanto, não precisamos nos preocupar muito com isso.

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

Carregamentos em paralelo

Analisando a hierarquia, podemos notar que, quando o carregamento do primeiro arquivo JavaScript termina, os novos arquivos começam a ser carregados imediatamente. Isso é comum para dependências de módulos. O 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 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 conferir o que nossas mudanças alcançaram. É importante não alterar nenhuma outra variável na nossa configuração de teste que possa distorcer os resultados. Portanto, usaremos a configuração simples do WebPageTest (link em inglês) para o restante deste artigo e observaremos a tira de filme:

Usamos as tiras de filme do WebPageTest para analisar os resultados das nossas mudanças.

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

Pré-renderização

Embora tenhamos reduzido nosso TTI, isso não afetou a tela branca eternamente longa que o usuário precisa suportar por 8,5 segundos. Pode-se argumentar que as maiores melhorias para a FMP podem ser alcançadas com o envio de marcação estilizada no index.html. 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 aplicativo da web em Node e serializam o DOM resultante em HTML. A renderização do lado do servidor faz isso de acordo com a solicitação do lado do servidor, enquanto a pré-renderização faz isso no tempo de build e armazena a saída como seu novo index.html. Como o PROXX é um app JAMStack e não tem no 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 qualquer IU e permite controlar remotamente essa instância com uma API Node. Ele é usado para injetar nossa marcação e nosso JavaScript e, em seguida, lermos o DOM como uma string de HTML. Como estamos usando módulos CSS, o CSS inclui os 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 em vigor, podemos esperar uma melhoria para nossa FMP. Ainda precisamos carregar e executar a mesma quantidade de JavaScript de antes, então não devemos esperar que o TTI mude muito. Caso haja algo, o index.html aumentou e pode atrasar um pouco a TTI. Há apenas uma maneira de descobrir: executando o WebPageTest.

A tira de filme mostra uma melhoria clara para nossa métrica de FMP. O TTI geralmente não é afetado.

Nossa Primeira exibição significativa passou de 8,5 segundos para 4,9 segundos, uma grande melhoria. Nosso TTI ainda acontece em cerca de 8,5 segundos e, por isso, não foi afetado por essa mudança. O que fizemos aqui foi uma mudança perceptível. Alguns podem até chamar de “atalho”. Ao renderizar um visual intermediário do jogo, estamos mudando para melhor o desempenho de carregamento.

Em linha

Outra métrica fornecida pelo DevTools e pelo WebPageTest é o Tempo até o primeiro byte (TTFB, na sigla em inglês). É o tempo decorrido desde o primeiro byte da solicitação enviado até o primeiro byte da resposta recebida. Esse tempo também é chamado de tempo de retorno (RTT), 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 visualizam o TTFB com uma cor clara dentro do bloco de solicitação/resposta.

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

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

Esse problema foi a finalidade original do envio de HTTP/2. O desenvolvedor do app sabe que determinados recursos são necessários e pode adiá-los. Quando o cliente percebe que precisa buscar recursos adicionais, ele já está nos caches do navegador. O envio de HTTP/2 por push foi muito difícil de acertar, por isso ele não é recomendado. Vamos rever este espaço problemático durante a padronização do HTTP/3. Por enquanto, a solução mais fácil é in-line todos os recursos críticos em detrimento da eficiência do armazenamento em cache.

Nosso CSS essencial já está embutido graças aos módulos CSS e ao nosso pré-renderizador baseado no Puppeteer. Para JavaScript, é necessário alinhar nossos módulos essenciais e as dependências deles. A dificuldade desta tarefa varia de acordo com o bundler que você está usando.

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

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

Divisão agressiva de código

Sim, nossa index.html contém tudo o que é necessário para se tornar interativo. Mas, uma análise mais detalhada, descobrimos 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 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 de PROXX. Somente 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 do que o pacote consiste. 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. 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 diminuirá 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 é a divisão de 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 (links em inglês), oferecem suporte à divisão de código usando import() dinâmico. O bundler vai analisar o código e in-line todos os módulos importados estaticamente. Tudo o que você importar dinamicamente será colocado no próprio arquivo 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 objetivo é importar estaticamente os módulos críticos necessários no tempo de carregamento e carregar dinamicamente todo o restante. Mas não espere até o último momento para fazer o carregamento lento de módulos que com certeza serão usados. Idle até Urgent de Phil Walton é um ótimo padrão para um meio termo saudável entre o carregamento lento e o rápido.

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 processar componentes carregados lentamente de fábrica. 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 em vigor, podemos usar uma promessa de um componente em nossas funções render(). Por exemplo, o componente <Nebula>, que renderiza a imagem animada de plano de fundo, será substituído por uma <div> vazia enquanto o componente estiver sendo carregado. Depois que o componente for carregado e estiver 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 definido, reduzimos o index.html para apenas 20 KB, menos da metade do tamanho original. Que efeito isso tem sobre FMP e TTI? O WebPageTest contará!

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

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

Mais inteligência

Se você observar nossa lista de módulos críticos acima, verá que o mecanismo de renderização não faz parte dos módulos críticos. É claro que o jogo não pode começar até que nosso mecanismo de renderização o renderize. Podemos desativar o botão "Iniciar" até que o mecanismo de renderização esteja pronto para iniciar o jogo, mas, segundo nossa experiência, o usuário costuma levar tempo suficiente para definir as configurações do jogo, o que não é necessário. Na maioria das vezes, o mecanismo de renderização e os outros módulos restantes são carregados assim que 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

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

A tira de filme pode fornecer insights sobre como é a sensação de carregamento do app para o usuário. A hierarquia pode informar quais recursos são responsáveis por possíveis tempos de carregamento longos. Confira uma lista de verificação do que pode ser feito para melhorar o desempenho do carregamento:

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

Fique de olho na parte 2, em que vamos discutir como otimizar o desempenho do tempo de execução em dispositivos hiperrestritos.