- Ao criar uma GUI de banco de dados para MongoDB e PostgreSQL, foi necessário oferecer suporte a tipos BSON e JSONB, colunas aninhadas, busca, edição, fixação e arrastar; para isso, a virtualização em dois eixos e a estrutura de gerenciamento de estado foram otimizadas ao longo de cerca de um ano
- Foi criada uma tabela sombra que pré-calcula, separadamente do documento original, strings de exibição, tipos, caminhos achatados, ordem das colunas e resultados de busca, renderizando apenas as linhas e colunas visíveis na tela com um DOM de tamanho constante
- No caminho de rolagem, foram aplicados listeners de eventos passivos,
requestAnimationFrame, buffers e histerese, além de rastreamento de velocidade; e, em vez de propriedades de layout, foram usadostransformeopacitypara reduzir o trabalho da thread principal - Ícones por célula foram substituídos por imagens SVG de fundo compartilhadas, o editor passou a ser montado apenas quando necessário, e o pooling de DOM, que rastreia linhas e colunas por posição, eliminou a criação de nós durante a rolagem
- Canvas oferece um teto de desempenho de 60fps maior que o DOM, mas é desfavorável para texto, seleção, acessibilidade e expansão de funcionalidades; por isso, foi escolhido um design baseado em DOM, mantendo seleção real de texto e desenvolvimento rápido
Objetivos e restrições iniciais
- Tudo começou com um array bidimensional simples e loops aninhados, mas acabou levando a cerca de um ano de otimizações intermitentes
- A tabela de uma GUI de banco de dados não era apenas para exibição; precisava dar suporte a diversos estados e interações
- Entender todos os tipos BSON do MongoDB e JSONB de PostgreSQL etc., exibindo ícones coloridos por tipo
- Distinguir tipos cujos resultados de consulta mudam, como a string
"123"e o inteiro123 - Expandir documentos aninhados em subcolunas reais e buscar em todo o caminho aninhado, destacando as partes correspondentes dentro da célula
- Eram necessários recursos como reordenação, redimensionamento e fixação de colunas, edição dentro da célula e arrastar valores, linhas e colunas para um construtor visual de consultas
- O estado desses recursos não existe no documento original e precisa persistir mesmo após a rolagem, portanto era necessária uma estrutura de renderização separada
Etapa 1: renderizar tudo diretamente
- A abordagem de percorrer linhas e campos em loops aninhados para criar todas as células funciona com 100 linhas, mas desmorona em dados de grande escala
- 10.000 linhas × 30 colunas geram cerca de 300.000 nós DOM, e a detecção de mudanças do framework os percorre repetidamente
- Considerando que um nó DOM consome cerca de 1KB incluindo estruturas internas do navegador, são necessários centenas de MB antes mesmo dos dados reais
- O orçamento de quadro para 60fps é de 16,7ms, e estilo, layout e pintura também compartilham esse tempo
- Na implementação real, a tentativa de renderizar 1.000 linhas com cerca de 20 colunas falhou
- Para renderizar apenas uma parte, é preciso rastrear separadamente as linhas e colunas atualmente visíveis, a ordem, a expansão de campos aninhados e os resultados de busca
Etapa 2: separar o estado de exibição com uma tabela sombra
- O documento original é aninhado e tem tipos variados, tornando-o difícil de usar como entrada de renderização
- No MongoDB há valores BSON como ObjectId, Decimal128, timestamp e binário
- Dados SQL têm JSONB e timestamps com fuso horário, e o formato precisa ser decidido célula por célula
- Se o formato for determinado no loop de renderização, o mesmo custo se repete a cada quadro; além disso, os dados originais não têm estados da tabela como ordem das colunas, estado de expansão e resultados de busca
- A tabela sombra funciona como a referência do estado que a tabela real deve mostrar, sem alterar o documento original
- É construída uma vez no carregamento e atualizada quando o estado muda, sem ser modificada durante a rolagem
- Para cada célula, pré-calcula a string de exibição truncada, o tipo definido e o caminho achatado
- Limita a string de exibição para que um documento de 16MB não gere uma string de 16MB inteira no estado de renderização
- O tipo determina o ícone, o editor e o modo de busca
- Usa caminhos achatados como
"address.geo.lat"como chave, evitando percorrer a árvore toda vez
- Ao expandir um objeto aninhado, os subcaminhos são promovidos a colunas reais; ordenação, resultados de busca, ordem das colunas e estado de expansão também ficam armazenados na mesma estrutura
- Nesta etapa, o número de nós DOM não diminui, mas é criada a base para calcular rapidamente a ordem e a largura das colunas depois
Etapa 3: virtualização vertical e área de rolagem fantasma
- A virtualização vertical renderiza apenas as linhas do viewport e um pequeno buffer, transformando a altura restante em uma área falsa
- A área fantasma (phantom) é um contêiner interno com altura
rowCount × rowHeight- Com 1 milhão de linhas × 40px, é criado um
divde 40 milhões de px de altura, quase sem conteúdo - O navegador usa essa altura como base para oferecer a barra de rolagem nativa e o comportamento de rolagem
- Com 1 milhão de linhas × 40px, é criado um
- O intervalo exibido é obtido com os seguintes cálculos
firstRow = floor(scrollTop / rowHeight)lastRow = floor((scrollTop + viewportHeight) / rowHeight)- Linhas de buffer são adicionadas em ambos os lados para não renderizar novamente a cada pequeno movimento
- As linhas exibidas são posicionadas em um contêiner slab na posição
firstRow × rowHeight; usartransformtende a ser melhor do quetop - O usuário vê 1 milhão de linhas, mas existem apenas cerca de 40 linhas no DOM
- Porém, com 300 colunas, só 40 linhas já geram 12.000 células, então as colunas também precisam ser virtualizadas
Etapa 4: virtualização horizontal com larguras variáveis
- Coleções de bancos de dados de documentos podem ter centenas de campos, então a virtualização de colunas também é necessária
- Como a largura das colunas não é constante, usa-se soma acumulada e busca binária em vez de divisão por valor fixo
- Com uma soma acumulada no formato
position[n] = width[0] + ... + width[n-1], a coordenada x de cada coluna é obtida com uma única consulta ao array - A coluna correspondente ao offset de rolagem
xé encontrada por busca binária no array de somas acumuladas - Mesmo com 1.000 colunas, a busca termina em microssegundos
- Com uma soma acumulada no formato
- A soma acumulada é reconstruída apenas quando a largura realmente muda, como ao redimensionar, ocultar ou reordenar colunas; não é criada durante a rolagem
- Um buffer de cerca de 200px à esquerda e à direita renderiza a próxima coluna antes que ela apareça na tela
- Após a virtualização em dois eixos, a área renderizada se mantém em cerca de 40 linhas × 12 colunas, independentemente do tamanho dos dados
- Mesmo em coleções com 500 colunas, apenas cerca de 12 colunas existem por vez, então o tempo de carregamento não aumentou
- O alvo de renderização foi reduzido, mas ainda restava o problema de recalcular o intervalo a cada evento de rolagem, que ocorre centenas de vezes por segundo
Etapa 5: gerenciar o orçamento de quadros no caminho de rolagem
- O processamento da rolagem compartilha o orçamento de quadro de 16,7ms com estilo, layout e pintura, portanto deve fazer apenas o mínimo necessário
- Listeners de eventos passivos são registrados fora da detecção de mudanças do framework
- Isso informa ao navegador que
preventDefaultnão será chamado, permitindo ao compositor mover pixels sem esperar pelo JavaScript - O próprio evento de rolagem não aciona verificações de renderização do framework
- Isso informa ao navegador que
- Vários eventos são combinados em um só por quadro
- Apenas a posição de rolagem mais recente é registrada, e um único callback de
requestAnimationFrameé agendado - Mesmo 12 eventos ocorridos em um quadro são tratados com um único cálculo de intervalo
- Apenas a posição de rolagem mais recente é registrada, e um único callback de
- Foi aplicado o encerramento rápido de desenho obtido no código-fonte do Handsontable
- Se o novo intervalo visível estiver dentro do buffer já renderizado, bastam duas comparações de inteiros e o retorno imediato
- Histerese é aplicada nas bordas do buffer
- Quando o intervalo visível se aproxima de cerca de 40px da borda do buffer, ele é reconstruído
- O buffer de cerca de 200px é reposicionado em torno da nova posição, evitando que bordas vazias apareçam ou que haja reconstruções repetidas na fronteira
- Foi adicionado um detector de velocidade que rastreia px/ms entre eventos
- Na implementação, acima de 10px/ms é considerado um flick rápido, e a renderização é interrompida
- A rolagem nativa continua se movendo sobre a área fantasma e, quando a velocidade estabiliza, o slab é preenchido novamente
- Mesmo após reduzir o trabalho de JavaScript, ainda havia queda de frames; layout e pintura, incluindo estilos, listras e ícones, tornaram-se o próximo gargalo
Etapa 6: remover o custo de propriedades de layout
- A thread principal do navegador cuida de estilo, layout e pintura, enquanto o compositor move na GPU camadas já desenhadas
- As propriedades que podem ter animação processada pelo compositor são
transformeopacity;top,left,width,height,background-coloretc. acordam a thread principal - Ao atualizar
toppara sincronizar a rolagem dos números de linha e do painel de colunas fixas, ocorria layout forçado 60 vezes por segundo- Isso foi substituído por
translate3d, mantendo a mesma aparência e eliminando o custo na thread principal
- Isso foi substituído por
- As listras que eram implementadas como fundo por linha foram trocadas por um único
repeating-linear-gradientno corpo inteiro- A altura da linha é passada como variável CSS
- O navegador rasteriza apenas um tile do tamanho de duas linhas e o copia repetidamente a partir da textura da GPU
- A vinculação de classes por linha desaparece, e o corpo e os painéis fixos compartilham o mesmo tile, evitando também divergências de cor
- As linhas divisórias de coluna são desenhadas apenas na altura do slab atualmente renderizado, não em toda a área fantasma de 40 milhões de px de altura
- A rolagem normal ficou suave, mas no momento de troca da janela, quando novas células eram criadas, ainda restava custo devido ao acúmulo de DOM desnecessário por célula
Etapa 7: tornar ícones e editores mais leves
- Os ícones de tipo em um grid de banco de dados não são decoração; eles indicam o significado de valores como ObjectId, string, inteiro e JSONB
- A primeira implementação adicionava um elemento de ícone de fonte em cada célula, mas os nós DOM extras e o caminho de renderização de texto dos glifos geravam alto custo
- Os ícones foram movidos para o
background-imageda própria célula e codificados como URI de dados SVG- Todas as células do mesmo tipo referenciam a mesma string de URI
- O navegador rasteriza o ícone de cada tipo apenas uma vez e o copia repetidamente a partir da textura em cache na GPU
- Decoração repetida pode ser exibida sem nós DOM separados
- Para edição de células, foi adotado um modo de renderização híbrido
- Em condições normais, a célula usa apenas texto comum e um único
span - Somente no duplo clique é que um componente pesado de editor sensível a tipos é montado como um portal sobre aquela célula
- Em condições normais, a célula usa apenas texto comum e um único
- Usar componentes do framework em todas as células acumula custo de criação de instâncias, e o desempenho pode cair ao acoplar uma biblioteca de grid a componentes renderizadores de célula
- As células em si ficaram leves, mas ainda restava o problema de o framework recriar até as demais células com conteúdo idêntico quando uma nova coluna aparecia
Etapa 8: reutilização de DOM baseada em posição
- Critérios de rastreamento como
trackByno Angular ekeyno React e Vue determinam se elementos existentes serão reutilizados ou destruídos e recriados quando a janela se move - Se uma linha não for vinculada novamente a novos dados e for substituída toda vez, componentes, nós DOM e listeners de eventos precisam ser recriados continuamente
- Linhas e colunas são rastreadas não pelo valor dos dados, mas pela posição na tela
- Os mesmos cerca de 40 elementos de linha e coluna são mantidos, e apenas o conteúdo é substituído por novos valores
- O grid passa a funcionar como um pool de objetos, sem alocar novo DOM durante a rolagem
- Nesse estágio, os custos de rolagem em dois eixos e reconstrução da janela foram resolvidos, mas ainda restavam ajustes finais, como uma oscilação de um frame ao soltar uma coluna ou um erro de meio pixel nos números de linha
Etapa 9: acabamento das interações detalhadas
- Durante o arraste de colunas, as colunas existentes são movidas com
transformcomo se estivessem na nova ordem; no drop, a reordenação e o reset detransformsão tratados no mesmo passe de renderização- O último frame de arraste e o primeiro frame reordenado ficam idênticos em nível de pixel, tornando a transição invisível
- Tooltips não são vinculados a cada célula; um único listener de hover delegado no contêiner cuida disso
- O tooltip é calculado apenas para a célula sob o cursor
- Até a geração cara de strings, como a impressão legível de valores JSONB em SQL, é adiada para o momento real do hover
- A borda de 1px da coluna de números de linha deslocava a linha de base do texto em meio pixel em relação às linhas de dados, causando tremor durante a rolagem
- Foi corrigido para que ambas as colunas usem o mesmo box model
- As listras são determinadas pelo índice absoluto da linha, não pela posição do DOM reutilizado, para que a cor não pisque quando a janela de virtualização muda
- Também foram mantidos os recursos que justificaram criar um grid próprio
- Expandir documentos aninhados em subcolunas reais, em vez de deixá-los como strings JSON
- Destacar dentro da célula as strings correspondentes em caminhos aninhados
- Arrastar valores tipados diretamente do grid para um construtor visual de consultas
- O custo de adicionar esses recursos a uma biblioteca de grid genérica era maior do que o custo de possuir o próprio renderizador
Etapa 10: ler o código-fonte de grids de alto desempenho existentes
- No aprendizado de performance de frontend, ler código-fonte de outros projetos foi o hábito mais valioso, e otimizações não documentadas estavam preservadas em repositórios públicos
- O AG-Grid, um grid DOM estudado, aplicava as seguintes técnicas
- Dividia o trabalho de DOM no tempo usando um orçamento explícito por quadro e uma fila de tarefas com prioridades
- Fazia hash do viewport de colunas para encerrar uma rolagem sem mudanças com uma única comparação de string
- Criava linhas na direção da rolagem, mostrando primeiro o conteúdo para onde o usuário está indo
- Criava novas células primeiro e adiava a destruição das antigas, para que o conteúdo novo fosse desenhado antes do conteúdo que desaparece
- Como listeners de eventos por célula eram um gargalo medido, usava delegação de eventos no nível do contêiner
- Grids baseados em Canvas ignoram o DOM e redesenham centenas de textos a cada quadro sem recálculo de layout e estilo
- Conseguem manter 60fps mesmo sob manipulação intensa, tendo um teto de desempenho maior que grids DOM
- Textos rasterizados fora da grade de pixels podem ficar borrados
- A seleção funciona apenas em nível de célula, e reticências não aparecem a menos que sejam desenhadas manualmente
- Cada novo recurso de célula exige acrescentar código de desenho e código de hit testing
- No fim, o DOM foi escolhido para manter texto nítido, seleção real de texto, acessibilidade e desenvolvimento rápido de recursos
- Mesmo sem atingir a suavidade do Canvas, foi um trade-off explícito considerando a experiência de usuário necessária e o custo de desenvolvimento
Ainda não há comentários.