Como os motores de navegadores modernos funcionam?

Indo a fundo em como os navegadores transformam códigos em pixels na tela

Antes usados principalmente para acessar páginas na Internet, os motores dos navegadores hoje são onipresentes: o Chromium para os softwares desktops como Visual Studio Code, Slack, Spotify, Discord, Battle.net, Dreamweaver CC, Evernote; o WebKit está presente no Safari, na App Store, no Apple Mail e diversos softwares Linux; a própria interface gráfica do Mozilla Firefox usa seu próprio motor, o Gecko (aos poucos sendo substituído pelo Servo).

Diante disso, poucos softwares sofreram tantas mudanças e buscas por otimização como os motores dos navegadores. Não somente devido às páginas Web mais pesadas usando React e WebGL, mas também devido aos apps que usam frameworks Web, como o Electron e Ionic, buscarem uma performance e experiência de uso cada vez mais próxima aos aplicativos nativos compilados. Então vamos nos adentrar em como os navegadores pegam simples e puro código em texto e transformam em imagens e animações na sua tela.

Motores CSS != Motores JavaScript

De maneira simples, os navegadores podem ser divididos em dois grandes módulos: o renderizador CSS e o motor JavaScript. Cada navegador possui essas duas partes de software muito bem divididas: no Chrome temos o Blink para layout e o V8 para JavaScript; no Firefox temos o Gecko e o futuro WebRender para layout e o SpiderMonkey para JavaScript; no Edge temos o EdgeHTML para layout e o Chakra para JavaScript; e no Safari temos o WebKit para layout e o JavaScriptCore para, pasmem, JavaScript.

Esses dois módulos possuem códigos diferentes, funcionam de maneiras diferentes e podem até mesmo ser escritas em linguagens diferentes. No caso do Firefox Quantum (v57) temos o SpiderMonkey escrito em C++ enquanto o Servo e o WebRender são escritos em Rust.

A parte do navegador que cuida do JavaScript é a mais complexa e envolve certo conhecimento técnico sobre programação de baixo nível e compiladores. Prometo que vou tentar simplificar ao máximo.

Por dentro dos motores JavaScript

Motores ECMAScript mais antigos, até meados dos anos 2000, funcionavam simplesmente como interpretadores. Com a necessidade crescente de um código JavaScript mais rápido e usando menos recursos para aplicações cada vez mais complexas, os motores passaram a traduzir o JavaScript para linguagem de máquina.

Esses novos motores traduzem o código JavaScript para linguagem de máquina usando um compilador JIT (just-in-time), ou seja, em tempo de execução, diferente do C++ que usa um compilador AOT (ahead-of-time) onde o código é convertido integralmente em linguagem de máquina antes de seu uso. Alguns motores JS traduzem direto para linguagem de máquina, sem a implementação de bytecodes, como o V8, enquanto outros traduzem para linguagem intermediária, como o SpiderMonkey — sendo esse um dos motivos pela qual o Chrome é mais rápido que o Firefox, tratando-se de JavaScript especificamente. Mas o conceito é o mesmo: converter JavaScript para linguagem de máquina; assim como seu objetivo: gerar o código mais otimizado possível no menor tempo possível.

Um motor ECMAScript não é um único software que realiza todas essas tarefas. Normalmente é composto de vários serviços que compartilham as atividades: analisar, interpretar, otimizar, acionar o garbage collector, entre outras. O SpiderMonkey, por exemplo, nomeia seu compilador JIT de IonMonkey e possui um repositório próprio.

Como primeira etapa, o código JavaScript é analisado — etapa chamada de parsing — e convertido para o chamado AST (sigla em inglês para Árvore Sintática Abstrata). Nesse processo uma análise léxica quebra seu código em tokens, ou símbolos léxicos. As ASTs são usadas pelos compiladores para representar a estrutura de um código. Portanto, um JavaScript básico como:

var nome = 'Brunno';
var texto = 'Meu nome é ' + nome;

Irá gerar uma árvore sintática como ilustrada abaixo:

Nesse exemplo simples o AST está apenas gerando VariableDeclarations, mas a medida que o código vai ficando complexo com funções, condicionais, objetos, essa árvore vai ficando complexa. Com o AST em mãos o compilador pode fazer seu trabalho de gerar o bytecode para o resto do motor fazer seu trabalho de compilar em linguagem de máquina com instruções para processadores específicos: x86, ARM ou RISC.

De forma simples, podemos dividir todo o processo em quatro etapas:

  1. Análise sintática (parsing): o motor analisa o código, converte em árvores sintáticas e tokens para que o compilador possa fazer seu trabalho;
  2. Compilação e otimização: transforma seu código ECMAScript em bytecode ou linguagem de máquina para ser executado com maior performance do que uma linguagem interpretada.
  3. Execução: já em linguagem de máquina, o código otimizado é executado. Nos motores mais modernos essa etapa acontece em paralelo, se aproveitando dos multithreads.
  4. Garbage collector: remove recursos desnecessários para liberar espaço na memória, etapa que também acontece em paralelo.

Importante citar que esses passos não acontecem de forma sequencial no código inteiro. Lembre-se que tudo acontece just-in-time e em paralelo. A análise de uma parte do código acontece, depois a compilação de outra, depois o garbage collector, depois mais análise. Isso permite uma rápida execução do código JavaScript que realmente interessa no momento.

Dá pra citar também alguns desafios que os motores JS podem enfrentar. O JavaScript é uma linguagem de tipagem fraca, enquanto o C++ é uma linguagem de tipagem forte. Para um compilador isso significa que o C++ pode alocar espaços fixos para cada tipo trazendo maior otimização. Vamos ilustrar! No caso do JavaScript eu posso — mas não deveria — fazer aberrações do tipo:

var teste = 'Brunno'; // String
teste = 120; // Integer
teste = ['ValeStart', 'ContaExpert', 'AddictiveHub']; // Array

O compilador precisa lidar com os tipos de dados de maneira estrita, e não seria ideal o compilador alocar um número inteiro de 4 bytes num espaço de memória de 20 bytes apenas porque eu posso transformar um inteiro em string. Deveria então apagar e recriar endereços de memória? Ou deveria criar “cópias ocultas” das variáveis para cada um dos tipos? Em todos os casos são decisões que deixam seu código mais lento, e esses são os tipos de otimizações que os compiladores ECMAScript tem que lidar em microsegundos.


Motores de renderização: montando os layouts

Os motores de renderização são responsáveis por obter seu código HTML e CSS e transformar em coisas que podem ser vistas na tela. Os motores CSS também tem etapas bem definidas, e algumas se assemelham à forma como os motores ECMAScript trabalham (principalmente a parte de análise sintática). Vamos passo-a-passo!

Parse

É onde acontece a análise sintática do seu código HTML e CSS. Esses arquivos de texto são transformados em objetos que podem ser entendidos pelo navegador, transformando o CSS em regras, e o HTML em DOM Tree (sigla em inglês para Document Object Model).

Exemplo de DOM Tree de um código HTML

O DOM pode ser entendido como uma espécie de árvore que identifica como os elementos do seu código são relacionados entre si, relacionamentos que normalmente chamamos de parent e child. Nessa etapa o motor não renderiza absolutamente nada, apenas identifica como os elementos do DOM se relacionam, incluindo qual o tipo deste elemento (HTMLDocument, HTMLInputElement, HTMLMediaElement, CharacterData, Attr, entre outros).

Style

Com os elementos do DOM montados, o motor CSS identifica quais regras de estilo se aplicam a quais elementos do DOM.

Para ilustrar mentalmente, interprete como se cada regra CSS fosse uma etiqueta. O que o motor CSS faz se assemelha a colar as etiquetas em cada elemento do DOM, identificando as regras CSS que devem ser aplicadas a ele. O CSS funciona em cascata, sendo assim, estilos a elementos-pai podem ser herdados para elementos-filho, daí vem a parte do “cascading” do nome completo do CSS: Cascading Style Sheets. Em resumo, o CSS realiza duas coisas:

  1. Descobrir quais seletores se aplicam a quais elementos;
  2. Preencher quaisquer regras faltantes com as regras de seu elemento pai. Se elementos-pai não contém regras CSS então é usado o padrão do navegador — ou user agent style sheet.

Um arquivo CSS pode ter centenas, não raramente milhares, de propriedades CSS, muitas delas em comum, então o navegador usa o chamado style struct sharing. Isso significa que regras CSS não são replicadas em todos os elementos, e sim armazenadas um endereço de memória e os elementos HTML apenas recebem um ponteiro, trazendo maior rapidez na renderização e menor consumo de RAM. Então uma série de botões iguais lado-a-lado, por exemplo, compartilham o mesmo ponteiro (chamamos elementos irmãos dentro de um mesmo parent de siblings).

Layout

Nesse ponto, o motor de renderização lê as "etiquetas" em cada elemento do DOM, monta o layout e organiza a disposição dos blocos dos elementos na tela, ajustando tamanhos, posicionamentos, margens e colocando-os em ordem de exibição. Modificar a largura de uma caixa de texto, tamanho de fonte, entre outras coisas, alterará a disposição dos outros elementos na tela — redimensionará os blocos — , necessitando de recálculo por parte do layout.

Disposição do layout do meu perfil no Facebook

Paint

É quando acontece a estilização de preenchimento e texturas dos blocos de elementos, etapa que não necessita de cálculo de layout. O painting acontece em camadas pois cada elemento da tela pode ter sua estilização alterada independente das outras, isso acontece para evitar o repainting de todo o DOM, reduzindo o consumo de recursos da máquina.

Para uma ilustração mental, interprete como se fossem as camadas do Photoshop. Cada camada contém um elemento com sua própria estilização, mas ao colocar as camadas umas sobre as outras é formado o desenho completo. Se eu quiser mudar um botão de azul para vermelho, posso alterar apenas uma das camadas, mantendo as outras como estão.

Se tratando de CSS, o painting trata elementos que não necessitam de cálculo de layout, tais como text-decoration, background-[color, position, size, repeat], [box, text]-shadow, color, border-[style, radius, color], outline e z-index.

Composite e Render

Continuando nossas ilustrações mentais, interprete o composite como se fosse uma fotografia do desenho final com as camadas sobrepostas, ou um “Exportar PNG” do Photoshop. Essa composição final será o resultado a ser exibido na tela como pixels coloridos, o que chamamos de render.

Ao contrário das etapas anteriores, o composite é tratado pela GPU, ou seja, recebe aceleração gráfica por hardware. Como o tempo gasto pela CPU para renderização significa menos tempo disponível para trabalhar com o compilador JavaScript ou o layout, então a "montagem" final dos pixels fica por conta do processador gráfico. Guarde essa informação! Ela será exemplificada mais adiante.

Como o renderizador trabalha com tudo isso?

Ao contrário do que se imagina, o renderizador não faz isso apenas uma vez durante o carregamento da página, mas sempre que acontece alguma mudança nos pixels da sua tela. Alterar cor de fundo ao passar o cursor em cima de um elemento, usar um transition, adicionar um sublinhado no link, abrir um menu, uma tooltip, o navegador precisa de todos os cálculos novamente e executar todo o trabalho de renderização. Atualmente a Web trabalha em 60 frames por segundo, então isso significa que para que tudo corra suavemente todos esses cálculos devem ser feitos 60 vezes por segundo — layout, painting, composite e render em menos de 16,67ms. Mesmo ao dar scroll na tela ainda há trabalho de renderização, pois o navegador precisa calcular os bits de cores e exibir os novos pixels na tela em cada um dos frames, ao menos 60 vezes por segundo.

A ferramenta de "Timeline" do navegador monitora todos os cálculos realizados em cada um dos frames.

Acontece que nem sempre a CPU/GPU consegue fazer isso tudo em menos de 16,67ms, principalmente quando há muitos elementos com mudanças de estado — ou a área das mudanças é grande, como em um plano de fundo onde, apesar de ser uma camada, toda a página terá que sofrer repainting — , então acontece o chamado jank, uma queda de frames, pois o processador não consegue liberar a renderização de um frame a tempo da renderização do próximo frame iniciar, causando aqueles lags durante uma animação ou rolagem de tela.

Renderizadores modernos, como o WebRender, tem usado fortemente o paralelismo em seu favor. Fazendo os recálculos usando o poder dos processadores multicores e delegando tarefas de redesenho para a GPU, todo o trabalho de renderização pode acontecer mais suavemente. Mas a eficiência do mesmo depende muito do poder de hardware do usuário.

A GPU trabalha a renderização de páginas da Web de maneira muito similar aos games e softwares gráficos:

  1. As estruturas dos vértices da área do elemento a ser desenhado na tela são definidos numa etapa chamada vertex shading.
  2. A rasterização conecta esses vértices e transforma-os em pixels e formas geométricas manipuláveis.
  3. Os pixels dessa forma geométrica são preenchidos com cores (24 bits de canais de cor + alpha), etapa chamada de pixel shading.

Motores mais modernos como o Blink e o WebRender são capazes ainda de definir quais elementos serão processados e renderizados, analisando quais nodes do DOM estão ocultos na tela e removendo-os da fila de processamento, técnica chamada de culling, que também é aplicada em games. Isso garante que a CPU e GPU trabalhem apenas nos elementos que estão visíveis. Esse efeito pode ser notado ao exibir duas animações sofrendo jank (queda de frames por segundo), ao ocultar uma animação a outra poderá voltar a ser exibida de maneira mais fluida.

Alta performance em animações

Para programadores é muito importante entender como os renderizadores de navegadores funcionam para atingir uma melhor performance e fluidez em sites e aplicativos, principalmente ao usar frameworks que emulam aplicações nativas como o Electron, NW.js, Ionic ou React Native.

Como foi explicado anteriormente, todas as etapas do renderizador devem acontecer 60 vezes por segundo para atingir a máxima fluidez. Portanto devemos eliminar a maior quantidade de recálculos possíveis das etapas de layout e painting, que consomem CPU, degradam a performance e reduzem a taxa de FPS (frames por segundo) do seu scroll ou animação.

Os navegadores modernos tratam da seguinte forma: uma mudança no layout fará todo o recálculo de layout, paint e composite. Uma mudança no paint fará o recálculo de paint e composite. E uma mudança no composite deixará o trabalho da layer apenas a cargo da GPU.

Por via de regra: procure ao máximo evitar mudanças no layout, pois esta é a parte que há maior consumo de recursos de hardware.

Para isso temos as propriedades de CSS que atuam diretamente no composite, e são aceleradas por hardware. Para conseguir maior performance em animações, por exemplo, ao invés de usar propriedades de posição como left ou right, que atuam no layout e fará toda a cadeia de reprocessamento, você poderá usar o translate(x,y), que cria e movimenta uma nova layer e atua diretamente no composite. O mesmo ocorre com o uso do rotate(), scale() e opacity.

Uma curiosidade: apesar da propriedade opacity aparentemente alterar o estilo de preenchimento, ele na verdade é renderizado pela GPU pois modifica o canal alpha daquela camada. É por esse motivo que aplicar o opacity no elemento-pai também encadeará para todos os elementos-filho, pois você alterou o canal alpha de toda a camada.

Importante citar que apesar do transform melhorar a performance em animações por evitar as etapas completas de repainting, ela cria uma nova camada de composite, o que aumenta o uso de memória RAM. É fundamental usar as ferramentas do Dev Tools do seu navegador favorito para monitorar e equilibrar muito bem as duas coisas.

Mas o fundamental:

"Evitar recálculo" é o mantra que todo desenvolvedor deve ter para atingir um bom desempenho nas aplicações, seja montando CSS, seja com programação JavaScript, PHP, Ruby, C++, até mesmo SQL — evitando funções de cálculo como DATE() no meio da sua query.

Conclusão

Espero poder ter ajudado, de certa forma, a entender como os motores de renderização dos navegadores modernos funcionam. O conteúdo é muito extenso e muito técnico, tentar expor em uma linguagem fácil em um texto enxuto é um trabalho desafiador. Mas uma coisa é certa: entender a engenharia por trás desses mecanismos te ajudará a montar websites e aplicativos de alto desempenho com baixo consumo de recursos.