Twitter Lite — Alta performance com React e Progressive Web Apps em escala!

Image for post
Image for post
Paul Armstrong anunciando o post original!

Vamos entender, com detalhes, os cenários comuns e incomuns de performance em uma das maiores aplicações React.js e PWA, Twitter Lite

Criar uma aplicação web rápida, envolve vários ciclos de testes e mensuração de métricas para ver aonde estamos gastando tempo, entender o porque isso acontece e aplicar possíveis soluções.

Infelizmente, não existe um jeito rápido de arrumar isso. Com Twitter Lite, nós fizemos pequenas melhorias em várias áreas: tempo inicial de carregamento, renderização de componentes React (e prevenir que eles fação a re-renderização), carregamento de images e muito mais.

Curiosamente, a maioria das mudanças foram pequenas, mas elas valeram a pena, e o resultado é que conseguimos criar um dos maiores e mais rápidos progressive web applications.

Antes de continuar

Se você está apenas começando a medir e trabalhar para melhorar sua performance, eu recomendo você aprender a ler Flame Graphs, caso você já não saiba.

Cada seção abaixo inclui um print de tela das medidas gravadas com Chrome DevTools. Para deixar as coisas mais claras, eu marquei nas imagens as partes que estavam ruins (imagem esquerda) e o resultado da melhoria (parte direita).

Uma nota especial sobre timeline e flame graphs: Como nosso projeto inclui uma gama variada de aparelhos mobile, nós normalmente gravamos esses gráficos em ambiente de simulação: deixando nossa CPU 5x mais lenta e conexões 3G. Isso não é apenas mais realista, mas deixa os problemas bem mais aparentes. Alguns resultados podem ficar confusos caso você esteja usando o componente para profiling do React 15.4. Valores de performance para desktop tendem a ser maiores dos que ilustrados aqui

Otimizando para o navegador

Divida seu código baseado em rotas

Webpack é uma ferramenta poderosa, com uma linha de aprendizado difícil. Por algum tempo, nós tivemos problemas com CommonsChunkPlugin e do jeito que ele funcionava com algumas das nossas dependências circulares. Por causa disso, nós terminávamos com apenas 3 arquivos JavaScript, totalizando 1MB (420kb com gzip).

Carregando um, ou alguns arquivos JS bem grandes para poder executar seu site é um grande problema de performance para mobile, o usuário tem que esperar muito para interagir com seu website. Não é apenas a transferências do arquivo que sofre, o tempo para o navegador fazer o parse deles também aumenta.

Depois de muito bater a cabeça, nós finalmente conseguimos quebrar nossas dependências baseando a configuração em nossas rotas (exemplo abaixo). O dia realmente chegou quando esse snippet apareceu no nosso email:

Adicione pequenas divisões de código baseado em rotas. Um renderização inicial mais rápida da HomeTimeline é uma troca justa para um tamanho maior do app. Que é separado em 40 pedaços carregados em demanda, que são amortizados ao longo da duração da sessão do usuário. — Nicolas Gallagher

Nossa configuração inicial (esquerda) demorava 5 segundos para carregar o bundle principal, e depois de dividirmos nosso código baseado em rotas e usar o common chunks (direita), o carregamento fica em torno de 3 segundos (em uma conexão 3G simulada).

Isso foi realizado no início das nossas sprints de performance, mas apenas essa pequena mudança, foi suficiente para melhorar os resultados na auditoria do Google’s Lighthouse em nossa aplicação:

Evite funções que causem “Jank”

Durante várias iterações na nossa timeline infinita, nós usamos diferentes modos para calcular a posição e direção do scroll para determinar se nós precisaríamos chamar a API para carregar mais tweets. Até recentemente, nós estávamos usando react-waypoint, que funcinou muito bem para nós. Porém, na busca de uma melhor performance, para um dos principais componentes da nossa aplicação, ele não era rápido o bastante.

react-waypoints funciona calculando diferentes heights, widhts e posições de elementos em order para determinar qual a posição atual do scroll, qual a distâncias dos pontos finais (cima/baixo), e qual direção você está indo. Todas essas informações são úteis, mas, como é realizada em cada scroll, isso vem com um custo: esses cálculos causam o efeito jank — na realidade, muitos deles!

Mas, primeiramente, nós precisamos entender o que o DeveloperTools quer dizer quando ele nos mostra jank na timeline:

A maioria dos devices hoje em dia, fazem a atualização da tela em uma frequência de 60 vezes por segundo. Se alguma animação ou transição estiver acontecendo, ou se o usuário está scrollando a página, o navegador precisa sincronizar com a frequência de atualização do device, e adicionar esse nova animação/transição, ou quadro, para cada uma dessas atualizações de tela.

Cada um desse frames tem um preço em torno de 16ms (1 segundo / 60 = 16.66ms). Porém, na realidade, o navegador tem trabalho de casa para fazer, então, todo esse trabalho é reduzido para em torno de 10ms. Quando você falha em alcançar esse tempo, a taxa de atualização de frames cai e o conteúdo na tela demonstra lentidão, como se estivesse com lag. Isso é, frequentemente, nomeado como jank, e negativamente impacta a experiência do usuário — Paul Lews, Rendering Performance

Depois de um tempo, nós desenvolvemos nossa própria timeline infinita, chamada VirtualScroller. Com esse novo componente, nós tínhamos a segurança de saber quantos tweets estavam sendo renderizados na timeline em qualquer ponto, evitando a necessidade de calcular aonde o scroll/mouse estava visualmente.

Ao evitar chamadas para funções que causavam extra jank, realizar o scroll na timeline de tweets é bem responsiva, nos dando uma experiência muito rica, quase nativa.

Mesmo com os resultado do react-waypoints, nós sabíamos que podíamos melhorar. Essa mudança trouxe uma melhora visível na performance de scroll.

Foi uma boa lembrança de que cada pedaço conta, quando performance está em jogo.

Use imagens menores

Para diminuir o tráfego de bandwidth no Twitter Lite, trabalhamos com vários times para conseguir um novo tamanho de imagens disponíveis nas nossas CDNs. No final, diminuindo o tamanho das imagens, nós estávamos, absolutamente, renderizando apenas o que precisávamos (ambos em termo de dimensões e qualidade), nós descobrimos que, não reduzimos apenas o bandwidth, mas que aumentamos a performance no navegador, especialmente enquanto realizávamos o scroll com tweets carregados de imagens.

Em order para determinar se imagens menores eram melhores para performance, nós poderíamos olhar o tempo de Raster no Chrome DevTools. Antes de reduzirmos o tamanho das imagens, a decodificação de uma única imagem demorava em torno de 300ms, como é mostrado nas imagens abaixo (esquerda). Esse é o tempo de processo depois do download complete da imagem, e logo antes dela ser mostrada na página.

Quando você está realizando o scroll, e quer manter a performance dentro dos 60 quadros por segundo, nós queremos manter, o máximo possível, processos dentro de 16.667ms (1 quadro). Estava nos custando quase 18 frames para apenas uma única imagem ser renderizada na área visual atual do usuário. Que convenhamos, é um absurdo. Um outra coisa para na timeline: você pode ver que a Main timeline fica praticamente bloqueada de continuar outros processos, até que a imagem essa completamente decodificada (representado pelos espaços em branco). Nos mostrando, claramente, que nessa etapa, temos um buraco na nossa performance!

Otimizando o React

Faça uso do método shouldComponentUpdate

Uma dica comum para otimizar performance em aplicações React é usar o método shouldComponentUpdate. Nós fazemos isso aonde é possível, mas as vezes, as mudanças não são o que esperávamos:

Image for post
Image for post
Curtindo um tweet, causando renderização da conversa inteira abaixo!

Aqui um exemplo de um componente que está sempre sendo atualizado: Quando clicamos no ícone de coração para curtir um tweet, qualquer componente Conversation na página, também será re-renderizado. No exemplo animado acima, você pode ver as caixas verdes piscando, é a parte onde o navegador teve que realizar o re-paint, quando curtimos um tweet qualquer, todos os componentes Conversation, abaixo daquele tweet, são re-renderizados.

Abaixo, você pode ver dois flame graphs dessas ações. Um sem shouldComponentUpdate (esquerda), podemos ver toda a árvore sendo atualizada e re-renderizada, apenas para mudar a cor do coração em alguma parte da tela. Depois de adicionarmos shouldComponentUpdate (direita), nós prevenimos que toda a árvore seja atualizada, e também o desperdício de mais de um décimo de segundo rodando processo desnecessário.

Adie trabalho desnecessário até componentDidMount

Esse é um pequeno detalhe que aprendemos ao usarmos os métodos de ciclo de vida do React, que fazem toda a diferença para uma aplicação do tamanho do Twitter Lite.

Descobrimos que estávamos realizando cálculos em várias partes do nosso código apenas para analytics durante do método componentWillMount. Toda vez que fazíamos isso, nós bloqueamos a renderização de componentes cada vez mais. 20ms aqui, 90ms ali, tudo isso bem rapidamente. Originalmente, nós estávamos tentando gravar qual tweet estava sendo renderizado e enviar para esses dados para o nosso serviço de analytics, tudo isso no componentWillMount, antes mesmo desses componentes serem renderizados (timeline abaixo, na esquerda):

Movendo esse cálculo e a chamada para nosso serviço de analytics para componentDidMount, nós desbloqueamos a thread principal e reduzimos janks não desejáveis enquanto renderizamos nossos componentes (imagem à direita).

Evite usar dangerouslySetInnerHTML

No Twitter Lite, nós usamos icons SVG, é a opção mais portável e escalável disponível para nós. Infelizmente, em uma versão antiga do React, a maioria dos atributos SVG não eram suportados quando você criava elementos SVG de componentes. Então, quando nós começamos a escrever a aplicação, éramos forçados a usar dangerouslySetInnerHTML para podermos usar ícones SVG em componentes React.

Por exemplo, nosso ícone de Coração original parecia com:

O time do React não recomenda o uso de dangerouslySetInnerHTML, e no final, não foi só isso, mas também uma lentidão na montagem e renderização:

Analisando o flame graphs acima, nosso código original (esquerda) mostra que, em um device lento, demora em torno de 20ms para montar as ações abaixo do tweet contendo 4 ícones SVG. Pode parecer que não é muito, mas sabendo disso e sabendo que precisamos renderizar vários desses, de uma só vez, enquanto o scroll acontece em nossa timeline infinita de tweets, descobriamos que isso era um grande perda de tempo.

Desde o React v15, foi adicionado suporte para a maioria dos attributors SVG, com isso em mente, fomos ver o que aconteceria se não usássemos dangerouslySetInnerHTML. Verificando o flame graph atualizado (direita), nós salvamos em torno de 60% em cada vez que precisamos montar e renderizar esse conjunto de ícones!

Agora, nossos ícones SVG são simples componentes sem estados, não usamos dangerouslySetInnerHTML e montamos em média 60% mais rápido. Eles se parecem com:

Adie renderizações enquanto monta ou desmonta muitos componentes

Em devices de baixa configuração, nós percebemos que nossa navegação principal pode demorar um longo tempo para responder a taps, as vezes nos fazendo realizar múltiplos taps, parecia que o primeiro tap não estava sendo registrado.

Perceba na imagem abaixo que o ícone Home demora em torno de 2 segundos para atualizar e mostrar o que foi tapeado:

Image for post
Image for post
Sem adiar a renderização, a navegação demora um longo tempo para responder

Não é uma GIF rodando em baixo quadro. Realmente estava devagar assim. O interessante, todos os dados necessários já estavam carregados, porque raios estava demorando assim?

Descobrimos que montar e desmontar grandes árvores de componentes (como uma timeline de tweets), é muito expensivo em React.

No final, nós queríamos remover a percepção de que a barra de navegação não estava respondendo há ação do usuário. Para isso, nós criamos um pequeno high order component para lidar com isso:

Nosso HOC foi escrito por Katie Sievert

Uma vez aplicado a nossa HomeTimeline, nós percebemos uma reposta instantânea na nossa navegação, com uma melhora na percepção de resposta, no geral:

const DeferredTimeline = deferComponentRender(HomeTimeline);
render(<DeferredTimeline />);
Image for post
Image for post
Depois de adiar a renderização, a navegação responde instantaneamente

Otimizando Redux

Evite guardar estado muito cedo

Enquanto componentes controlados parecem ser o modo recomendado, transformar inputs em componentes controlados significa que eles devem atualizar e re-renderizar para cada keypress.

Isso não é muito custoso em um computador desktop 3GHZ, mas, em um pequeno aparelho mobile com CPU bem limitada, você irá notar um lag significativo enquanto digita — especialmente quando deleta muitos caracteres do input.

Para manter a composição de tweets, ao mesmo tempo que calculamos o número de caracteres restantes, nós estávamos usando um componente controlado e também passando o valor atual do input para a nossa Redux store a cada keypress.

Abaixo (esquerda), em um device com Android 5, cada keypress liderava uma mudança que custava em torno de 200ms de sobrecarga. Com uma digitação bem rápida, nós acabamos em uma situação bem ruim, com alguns usuários reportando que os caracteres inseridos estavam em ordem errada, resultando em uma frase errada.

Removendo o estado de rascunho do tweet da store do Redux, movemos a atualização em cada keypress para um estado local no componente React, fomos capazes de diminuir a sobrecarga em 50% (imagem da direita).

Realizando ações em lote ao invés de múltiplas ações únicas

No Twitter Lite, nós estamos usando redux com react-redux para linkar nossos componentes com as mudanças de estado do redux. Nós otimizamos nossos dados em áreas separadas dentro dessa store com Normalizr e combineReducers. Isso tudo funciona perfeitamente para previnir duplicações de dados e deixar nossas stores pequenas. Porém, cada vez que nós recebíamos novos dados, nós tínhamos que despachar múltiplas ações para coloca-los na store correta.

Do jeito que react-redux funciona, isso significa que cada ação despachada irá chamar nossos componentes conectados (chamados Containers), fazendo com que eles recalculem mudanças e possivelmente re-renderizem.

Nós usamos um middleware customizado para realizar ações em lote, existem outros middlewares para ações em lote por aí. Escolha um que encaixe no seu projeto, ou, escreve um você mesmo!

A melhor maneira de ilustrar os benefícios de ações em lote é usando o Chrome React Perf Extension. Depois do carregamento inicial, nós fazemos o pre-cache e calculamos DMs não lidas no plano de fundo. Quando isso acontece, nós mudamos várias entidades (conversas de usuário, mensagens recebidas, etc). Sem ações em lote (esquerda), você pode ver que acabamos com o dobro do número de re-renderizações em cada componente, ~16, e, quando unificamos (a direita), ficamos em torno de ~8.

Service Workers

Sabemos que Service Workers não estão disponíveis ainda em todos os navegadores, eles são uma parte inestimável do Twitter Lite. Quando disponíveis, nos os usamos para Push Notifications, para fazer o pre-cache de assets e muito mais. Infelizmente, como é uma nova tecnologia, ainda há muito a se aprender sobre performance nessa área.

Pre-Cache de assets

Assim como muitos produtos, Twitter Lite não está nem perto de estar pronto. Nós estamos em desenvolvimento ativo, adicionando novidades, fixando bugs e tornando-o mais rápido. Isso significa que, frequentemente, precisamos fazer o deploy de uma nova versão do nosso JavaScript.

Infelizmente, não podemos sacrificar nossos usuários, fazendo-os baixar vários arquivos JavaScript só para ver um único tweet.

Em browsers com Service Worker ativado, nós podemos nos beneficiar e usarmos o SW para fazer uma atualização automática, realizar o download, e fazer o cache de qualquer arquivo mudado, tudo em plano de fundo, antes mesmo de você voltar para conferir os novos tweets.

O que isso significa para o usuário? Um carregamento da aplicação quase instantâneo, mesmo depois do deploy de uma nova versão!

Como ilustrado acima (esquerda), sem SW, é necessário carregar cada asset, em uma visita subsequente na aplicação. Isso pode demorar em torno de 6s em uma boa conexão 3G. Porém, se usarmos SW para fazer o pre-cache (direita), a mesma conexão 3G leva em torno de 1.5s, até a página ser carregada. Uma melhoria de 75%!

Adie o registro do Service Worker

Em várias aplicações, é seguro registrar o SW no carregamento da página:

<script>
window.navigator.serviceWorker.register('/sw.js');
</script>

Mesmo tentando enviar o máximo de dados para o navegador renderizar uma página completa o mais rápido possível, no Twitter Lite, isso nem sempre é possível. Talvez não conseguimos enviar dados suficientes, ou a página que você está visualizando não suporta dados pré carregados do servidor. Por causa de várias limitações, nós precisamos fazer algumas chamadas há API imediatamente depois do carregamento da página.

Normalmente, isso não é um problema. Porém, se o navegador ainda não tiver instalado a versão atual do SW, nós precisamos dizer para o navegador instala-la e com isso, vem uma chuva de 50 requisições do pre-cache para JS, CSS e arquivos de imagem.

Quando estamos usando a simples ação de registrar nosso SW imediatamente, nós podemos analisar nossa aba network, a luta entre requisições, maximizando o limite de requisições paralelas (esquerda).

Adiando a instalação do SW até termos terminado as requisições extras para nossa API, CSS e imagens, nós permitimos a página ser carregada e finalizada, tornando-o responsiva e interativa para nosso usuário. Assim como ilustrado na imagem a direita.

Notas finais

No geral, essa é uma lista de melhorias que fizemos no Twitter Lite durante esse tempo. Com certeza temos mais desafios pelo frente, e esperamos continuar compartilhando os problemas que nós encontramos e os caminhos que tomamos para resolve-los. Para saber mais sobre o que acontece em tempo real e mais sobre React & PWA, você pode seguir eu e meu time no Twitter.

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store