TK
Home

Investigação e Otimização de web performance com o DevTools

Algumas semanas atrás eu estava desenvolvendo um novo recurso de autenticação para o site FindHotel e depois de terminar a implementação, comecei a testar as mudanças.

Uma coisa que notei foi o quão lenta era a experiência de scroll e o quanto eu deveria esperar até poder clicar em elementos da página e receber feedback. A interatividade da página era lenta e jank. Então comecei a investigar esse problema. E eu sabia que era um problema de "performance".

Para ter uma boa compreensão desse problema, vamos começar com o funcionamento dos navegadores.

Navegadores e o processo de renderização

Não será uma descrição completa e exaustiva de como os navegadores funcionam, mas meu pensamento é dar uma ideia sobre o processo de renderização e falar um pouco sobre algumas partes móveis desse processo.

As duas primeiras etapas são sobre a construção do DOM e do CSSOM. O documento HTML é baixado do servidor e analisado no DOM (Document Object Model).

E as fontes CSS também são baixadas e parseadas no CSSOM (CSS Object Model).

Juntos, eles são combinados em uma árvore de renderização. Esta árvore contém apenas os nós necessários para renderizar a página.

O Layout é uma grande parte deste processo. Ele calcula o tamanho e a posição de cada objeto. Layout também é chamado de Reflow em alguns navegadores.

E por fim, a árvore de renderização está pronta para ser “pintada”, renderiza os pixels na tela e o resultado é exibido no navegador.

Gecko, a engine usado pelo Mozilla Firefox tem um vídeo bem interessante sobre como funciona o Reflow

Os Reflows são extremamente caros em termos de performance e podem fazer com que as velocidades de renderização diminuam significativamente. É por isso que a manipulação do DOM (inserção, exclusão, atualização do DOM) tem um alto custo, pois faz com que o navegador faz o reflow.

Um curso rápido sobre investigação de problemas de performance com DevTools

A partir desta base, vamos agora começar a aprofundar no problema. Na minha experiência, vi que a interface do usuário estava lenta e os pixels demoravam para renderizar e havia um bloco branco (quando os pixels ainda não estavam renderizados) ao fazer scroll da página.

Pode ser uma variedade de razões pelas quais a página teve esse problema. Mas sem medir o performance, eu não poderia realmente saber. Vamos começar a fazer o profiling da página.

Com o DevTools, consegui ter uma visão geral da interface do usuário usando o Performance Monitor.

Aqui temos acesso a um conjunto de atributos diferentes:

  • O uso da CPU
  • Tamanho do JS Heap
  • Nós do DOM
  • Listeners de eventos JS

E outros atributos que não adicionei ao Performance Monitor.

Interagir com a página e ver esses atributos mudarem foi a primeira coisa que fiz para entender melhor o que estava acontecendo ali.

Os nós do DOM eram grandes, mas o atributo que me chamou a atenção foi o uso da CPU. Ao rolar a página para baixo e para cima, a % do uso da CPU sempre foi 100% ou em torno dela. Agora eu precisava entender por que a CPU estava sempre ocupada.

O Chrome devtools é uma boa ferramenta para investigar possíveis problemas de performance.

Podemos usar a limitação da CPU para simular como um usuário em um dispositivo mais lento experimentaria o site. No vídeo, configurei isso para desacelerar 4x.

A tab de performance é muito útil e overwhelming ao mesmo tempo porque possui muitas funcionalidades e é fácil se perder em tantas informações em apenas uma guia.

Por enquanto, vamos nos concentrar em duas coisas: os frames e a Main Thread.

No vídeo, você pode ver que eu estava passando os frames e mostra as ações e interações que foram gravadas. Na Main Thread, você pode ver o flamechart, um monte de tasks que foram executadas. Com ambas as informações, podemos combinar os quadros com as tasks que levaram mais tempo para serem computadas.

Pensando nisso, surge um novo conceito: Long Tasks.

Uma Long Task é "qualquer período ininterrupto em que o main thread da interface do usuário esteja ocupado por 50 ms ou mais". Como sabemos, JavaScript é single threaded e toda vez que o main thread está ocupada, estamos bloqueando qualquer interação do usuário, levando a uma experiência muito ruim.

Podemos aprofundar cada Long Task e ver todas as tasks relacionadas que ela está computando. Com o flamechart, fica mais fácil entender as partes do aplicativo que estão causando as Long Tasks ou, em muitos casos, quais são os grupos de ações e componentes que estão causando o problema de performance.

Outra informação interessante do DevTools é a aba Bottom-Up.

e ver um monte de informações sobre as atividades na task. É um pouco diferente do flamechart porque você pode classificar as atividades com base no custo (Tempo Total) e também filtrar as atividades relacionadas ao código da sua aplicação, pois mostra outras informações como custo do garbage collector, tempo para compilar o código e avaliar o script, e assim por diante.

É muito interessante investigar as tasks usando o flamechart junto com as informações na guia Bottom-Up. Se você estiver usando o React, é mais fácil encontrar coisas como performSyncWorkOnRoot e performUnitOfWork, isso provavelmente está relacionado a como o React renderiza e re-renderiza seus componentes. Se você estiver usando o Redux, provavelmente verá coisas como dispatch e solicitando ao React que atualize alguns componentes.

Fazendo profiling da página de busca com o DevTools

Fazendo o profiling da performance da página em que estava trabalhando, encontrei as long tasks e tentei combinar essas tasks com os quadros (componentes que estavam renderizando/re-renderizando).

Na Main Thread, pude ver muitas Long Tasks. Não apenas o número de Long Tasks era um problema, mas também o custo de cada task. Uma parte importante de todo o processo de criação de perfil é encontrar a parte de sua base de código no flamechart de task longa. À primeira vista, pude ver coisas como onOffersReceived, onComplete e onHotelReceived que são ações de retorno de chamada após buscar dados por meio de uma API.

Eu me aprofundei em uma das Long Tasks e pude ver o Redux despachando uma ação e o React "realizando unidades de trabalho" (também conhecido como renderizando novamente os componentes que usam o estado Redux).

Todo o fluxo de trabalho desta página se parece com isso:

  • A página realiza uma solicitação de API: solicitando ofertas, hotéis e outros dados
  • Com a resposta da API, despachamos uma ação Redux para atualizar o estado na loja
  • Todos os componentes que usam esse estado atualizado serão renderizados novamente

Ações de dispatch e re-renderização de componentes causam as Long Tasks mais caras.

E não é de um único componente que estamos falando. Como esta página contém uma lista de hotéis e ofertas, sempre que o estado mudar, MUITOS componentes serão renderizados novamente. No gráfico de chamas é possível ver os componentes mais caros quando se trata de re-renderização.

Virtualização de listas: otimizações de tempo de execução

Agora que sabemos que reflow, repaint e manipulação do DOM, como adicionar e atualizar elementos no DOM, têm um custo significativo, algumas soluções para esse problema vêm à mente: reduzir a rerenderização de componentes e reduzir os elementos no DOM.

Um padrão comum para listas é usar a virtualização de listas.

Com a virtualização de lista, apenas um pequeno subconjunto da lista será renderizado no DOM. Quando o usuário rolar para baixo, a virtualização de lista removerá e reciclará os primeiros itens e os substituirá por itens mais recentes e subsequentes na lista.

No vídeo, você pode ver o DOM sendo atualizado. Ele renderiza apenas 3 elementos no DOM e quando o usuário começar a rolar, ele reciclará e substituirá os primeiros itens pelos itens subsequentes na lista. Podemos ver o data-index mudando. Ele começa com um índice 0 e renderiza apenas 0, 1 e 2 no início. Então 0 torna-se 1, 1 torna-se 2 e, finalmente, 3 é o novo elemento renderizado no DOM.

Temos um conjunto de bibliotecas de janelas que fazem esse trabalho muito bem para nós. Eu considerei react-window, mas no final, escolhi react-virtuoso para testar e analisar os resultados.

Gravando tanto a abordagem atual que usamos para renderizar a lista quanto a solução com virtualização de lista, pude ver melhorias na última.

localhost #2 — página de pesquisa atual sem virtualização de lista: tasks mais longas, long tasks custando mais, mais uso da CPU e experiência de rolagem lenta

localhost #1 — com virtualização de lista: menos long tasks, long tasks custando menos, menos uso de CPU e uma experiência de rolagem mais suave

Exemplos de long tasks nesta página são onOffersReceived e onComplete. Eles custam cerca de 400ms e 300ms respectivamente na versão atual. Com a virtualização de listas, o custo diminuiu para cerca de 70ms e 120ms respectivamente.

Em resumo, com a virtualização de listas, renderizamos menos elementos no DOM, menos componentes são renderizados novamente, o que reduz o número de long tasks e o custo de cada task, o que proporciona uma experiência de renderização suave e suave.

Outros benefícios incluem interatividade mais rápida do usuário (Time To Interactive), menos imagens baixadas na primeira renderização e escalabilidade quando se trata da paginação da lista.

  • Interatividade do usuário mais rápida: menos long tasks/custos menores de long tasks tornam a Main UI Thread menos ocupada e mais rápido para responder às interações do usuário.
  • Menos imagens baixadas: como renderizamos apenas 3 elementos no dom por "janela", também precisamos baixar apenas 3 imagens por janela (cada imagem para cada cartão de hotel).
  • Escalabilidade da paginação da lista: quando o usuário começa a clicar em "carregar mais" itens, a abordagem atual anexa mais itens ao DOM e a Main Thread precisa lidar com mais elementos, aumentando o custo de atualizações e re-renderizações de componentes.

Uma coisa a mencionar e considerar nesta análise é que estamos baixando uma nova biblioteca no pacote final. Este é o custo do tempo de download do react-virtuoso: 347ms (slow 3g) / 20ms (emerging 4g).

Otimizações e experimentos futuros

Este post é apenas a primeira otimização que fiz para melhorar a performance e, portanto, a experiência do usuário. Existem outros experimentos e investigações que quero fazer e compartilhar nos próximos posts.

Coisas na minha lista de tarefas:

  • Investigação dos componentes e hooks mais custosos: re-arquitetar, fazer cache, reduzir as re-renderizações
  • Obtendo apenas atributos de referências de objetos para reduzir re-renderizações
  • Código dividido em partes da página (coding splitting), como dialog de login, banners e footer para reduzir o tamanho do JavaScript no bundle final: menos JS, menos custo para a Main Thread
  • Medir a performance do tempo de execução com testes automatizados como o Playwright
  • E muitas outras investigações e experimentos que quero fazer

Recursos

Existem muitos recursos sobre a performance da web por aí. Se você está interessado neste tópico, você definitivamente deve seguir o repositório Web Performance Research.

Lá estarei atualizando com novos recursos, fazendo mais pesquisas e escrevendo minhas descobertas.

Twitter Github