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.