3 pontos por GN⁺ 3 시간 전 | Ainda não há comentários. | Compartilhar no WhatsApp
  • 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 usados transform e opacity para 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 inteiro 123
    • 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 div de 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
  • 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; usar transform tende a ser melhor do que top
  • 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
  • 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 preventDefault nã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
  • 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
  • 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 transform e opacity; top, left, width, height, background-color etc. acordam a thread principal
  • Ao atualizar top para 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
  • As listras que eram implementadas como fundo por linha foram trocadas por um único repeating-linear-gradient no 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-image da 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
  • 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 trackBy no Angular e key no 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 transform como se estivessem na nova ordem; no drop, a reordenação e o reset de transform sã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.

Ainda não há comentários.