JSON Toolkit

Por que árvores JSON grandes engasgam: rolagem virtual e estratégias de recolhimento

Resumo: O engasgo começa quando os nós de DOM são criados, não quando você rola. Cobre renderização com recolhimento primeiro, listas virtuais, destaque de busca e fronteiras de Worker, com métricas.

Avançado~10 min de leituraPublicado em 2026-10-31Atualizado em 2026-10-31
Rolagem virtual e estratégia de recolhimento para um visualizador JSON

Resumo da ópera: divida "lento" em análise, criação de nós, layout e pintura, e interação, e depois meça cada etapa. Implemente nesta ordem — recolha por padrão, adicione uma lista virtual, mova o cálculo para um Worker e restrinja o realce às linhas visíveis. Pular direto para uma lista virtual sem recolher só encolhe o conjunto visível; o recálculo da árvore inteira ainda custa.

O engasgo normalmente vem das centenas de milhares de nós de DOM que você criou para aqueles 5MB, não dos 5MB em si.

1. Divida "lento" em quatro etapas

Quando um usuário diz "congela ao abrir", o problema pode estar em quatro etapas completamente diferentes, cada uma com um remédio diferente:

Etapa Gatilho típico Limite perceptível Custo dominante
Analisar e construir objetos Abrir um arquivo, colar texto Qualquer tarefa acima de 50ms Varredura de strings, construção do grafo de objetos
Criar nós de DOM Renderizar a árvore inteira de uma vez Dezenas de milhares de nós Alocação de elementos, nós de texto, objetos de estilo
Layout e pintura Primeira exibição, rolagem Qualquer frame acima de 16,7ms Geometria, reflow, repintura
Resposta à interação Expandir, recolher, buscar Acima de 100ms parece atrasado Reachatar e re-renderizar

A thread principal do navegador faz uma coisa por vez. Se qualquer etapa a ocupa por mais de 50ms, a rolagem e a digitação perdem quadros; várias dessas tarefas em sequência e a página parece morta. A primeira otimização, portanto, não é requestAnimationFrame; é medir qual etapa está realmente gastando o tempo.

2. A contagem de nós é o verdadeiro inimigo

Visualizadores de árvore multiplicam contagens de nós por design. Uma única linha JSON normalmente vira "indentação + chave + dois-pontos + valor + vírgula + chevron", então uma estimativa conservadora é de 3 a 6 nós de DOM por linha:

Tamanho do arquivo Linhas aproximadas Nós de DOM na visão em árvore (estimativa) Como fica a renderização de uma vez
100 KB 2.500 10.000 Normalmente tolerável
1 MB 25.000 100.000 Claramente travado, possivelmente segundos de tela em branco
5 MB 125.000 500.000 Muito provavelmente sem resposta
20 MB 500.000 Mais de 2 milhões Memória e layout colapsam

Cada elemento é mais do que algumas dezenas de bytes de objeto: ele carrega regras de estilo, uma caixa de layout e instruções de pintura. Uma vez criado, um nó ainda custa trabalho de layout e estilo mesmo que depois seja escondido por um pai recolhido, desde que não tenha sido realmente removido. "Renderizar tudo e depois esconder" não é uma otimização; só adia o custo para o próximo reflow.

3. Recolha primeiro, virtualize depois

A rolagem virtual resolve "linhas visíveis demais". O recolhimento resolve "não deveríamos precisar ver tantas linhas". A ordem importa: para um documento de um milhão de linhas expandido por padrão, até uma lista virtual perfeita precisa recalcular a árvore inteira quando o usuário clica em "recolher tudo".

Uma estratégia de recolhimento viável:

  • Expanda apenas os dois primeiros níveis por padrão. A raiz e seus campos imediatos ficam visíveis; níveis mais profundos mostram um resumo como items: Array(12000);
  • Coloque tipo e contagem no resumo. Usuários verificando "os nomes dos campos estão certos" raramente precisam de dez mil registros;
  • Faça cache de linhas por subárvore. Cada nó expandido possui seu array de linhas achatadas; recolher o descarta, e expandir recalcula apenas aquela subárvore;
  • Nunca revarra a árvore inteira a cada alternância. Se a expansão acontece na linha 3.000, substitua apenas aquele intervalo e mantenha as linhas ao redor intactas.
/**
 * Insere as linhas de uma subárvore depois do seu pai em vez de reacatar a árvore inteira.
 * @param rows Array atual de linhas visíveis
 * @param parentIndex Índice da linha pai
 * @param childRows Linhas achatadas da subárvore
 * @returns Um novo array de linhas visíveis
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

O padrão a evitar é "uma expansão, um relayout completo". Repercorrer o grafo de objetos completo a cada clique faz o custo crescer linearmente com o documento. Com cache por subárvore, uma expansão única custa apenas o que aquela subárvore custa.

4. Listas virtuais: renderize apenas o viewport

Uma vez que o recolhimento reduziu a contagem de linhas, o restante ainda pode ter dezenas de milhares de linhas. Uma lista virtual define a altura do contêiner como "total de linhas x altura fixa da linha" e cria elementos apenas para o viewport mais um pequeno buffer acima e abaixo.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Calcula o intervalo de linhas que o viewport deve renderizar.
 * @param rows Todas as linhas visíveis
 * @param scrollTop Deslocamento atual de rolagem do contêiner
 * @param viewportHeight Altura visível do contêiner
 * @returns Linhas a renderizar, o índice inicial e o deslocamento vertical
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

Restrições inegociáveis:

  • A altura da linha precisa ser fixa. Só assim scrollTop / ROW_HEIGHT lhe dá o índice diretamente. Permitir linhas quebradas força você a medir e fazer cache de cada altura, o que acrescenta uma ordem de magnitude em complexidade e instabilidade;
  • Desloque com transform ou posicionamento absoluto. Não empurre o conteúdo para baixo com um elemento espaçador, ou cada rolagem muta o layout;
  • Use chaves estáveis. Chaveie as linhas por caminho, não por índice de array, ou expandir uma linha remonta todas as linhas depois dela;
  • Faça aritmética apenas no handler de rolagem. Adie a renderização para requestAnimationFrame e proteja contra agendamento duplicado;
  • Mantenha o buffer pequeno. Cinco a dez linhas de cada lado bastam; dobrar o buffer dobra os nós de DOM e o trabalho de estilo.

Com um viewport de 800 pixels e linhas de 24 pixels, cerca de 34 linhas ficam visíveis, e mesmo com o buffer você renderiza por volta de cinquenta. O documento subjacente pode conter cem mil linhas enquanto o DOM mantém consistentemente algumas centenas de elementos, mantendo o custo de layout plano.

5. Recolher, expandir e ancoragem de rolagem

Uma vez que o índice de linha se torna a única coordenada, alguns detalhes de interação começam a quebrar:

  1. Preserve a âncora visual ao recolher acima do viewport. As linhas abaixo deslizam para cima; se scrollTop não for ajustado no mesmo commit, o conteúdo parece saltar. Registre o caminho da linha âncora e restaure a posição de rolagem usando seu novo índice.
  2. Expanda os ancestrais antes de pular. Quando uma busca encontra um campo profundo, primeiro expanda todos os pais, depois calcule o índice da linha alvo e então role e destaque. Pule a primeira etapa e a linha alvo nem sequer estará no array visível.
  3. Dirija a navegação por teclado a partir das linhas visíveis. Cima e baixo movem o índice de linha; esquerda e direita expandem e recolhem. Nunca percorra a árvore inteira dentro de um handler de tecla.
  4. Agrupe atualizações em um frame. Ao expandir vários níveis em sequência, mescle as mudanças do array e renderize uma vez, ou cada nível dispara um reflow completo.

6. Busca e destaque: calcule uma vez, exiba pouco

A busca é a armadilha de desempenho mais comumente negligenciada em um visualizador de árvore. Se cada tecla percorre o grafo de objetos inteiro e reconstrói a lista de resultados, um documento de cem mil nós congela instantaneamente.

Uma abordagem em camadas:

  • Aplique debounce de 150-250ms na entrada e marque cada busca com um número de sequência para que resultados desatualizados sejam descartados;
  • Varra em um Worker. Percorrer um grafo de objetos é cálculo puro sem dependência de DOM, então não deveria bloquear a digitação;
  • Armazene apenas caminhos e índices de linha. Não copie cada nó correspondente nem pré-construa HTML destacado;
  • Destaque apenas as linhas visíveis. O viewport contém algumas dezenas de linhas; aplique estilos por caminho correspondente no momento da renderização;
  • Reutilize "expandir ancestrais e depois localizar o índice" para saltos, o mesmo caminho usado ao clicar em um chevron.

Um detalhe de segurança: chaves e valores em um visualizador de árvore são dados do usuário. Construir HTML destacado a partir deles exige escape, ou uma string contendo marcação vira XSS. Um visualizador JSON não precisa de rich text, então nós de texto mais nomes de classe são ao mesmo tempo mais seguros e mais rápidos.

7. Realce de sintaxe: CodeMirror e Monaco são ferramentas diferentes

Uma visão em árvore e um editor de texto são modelos de renderização diferentes, então "qual é mais rápido" não é uma comparação direta:

Dimensão CodeMirror 6 Monaco Editor
Posicionamento Núcleo de editor modular Editor de IDE completo
Renderização Baseada em viewport Baseada em viewport, mas modelos e decorações são mais pesados
Análise Análise incremental Lezer sobre intervalos alterados Tokenizador Monarch, linha a linha
Arquivos grandes Depende de extensões; leve por padrão Completo em recursos, maior custo de memória e inicialização
Dobramento e busca Extensões separadas, carregadas sob demanda Embutidos, com overhead permanente
Armadilha típica Extensões demais restauram o custo Minimap, sticky scroll e decorações empilhadas

Duas conclusões práticas:

  • Uma visão em árvore não precisa de um tokenizador completo. Dê a chaves, strings, números e booleanos uma cor cada com nós de texto simples e classes CSS; isso custa uma ordem de magnitude menos do que rodar realce de sintaxe;
  • Não monte uma visão em árvore e um editor ao mesmo tempo. Manter dois modelos para um documento dobra a memória; destrua a visão antiga em vez de escondê-la.

Se um editor realmente pertence ao visualizador, o CodeMirror 6 convém a "somente leitura, arquivo grande, realce local", enquanto o Monaco convém a autocompletar, diagnósticos e edição multi-cursor. Meça o tempo de primeira pintura e a memória residente antes de ler a lista de recursos. Para uma comparação mais profunda de editores e ferramentas de CLI, veja Editores, ferramentas de linha de comando e automação.

8. Fronteiras de Worker: mova análise e construção de linhas para fora da thread principal

A outra metade do engasgo de renderização é a análise na thread principal. Para JSON acima de 1MB, análise, formatação e construção de linhas achatadas pertencem a um Web Worker; a thread principal só recebe linhas e pinta o viewport.

Workers não são de graça: postMessage usa clonagem estruturada, então enviar um grafo de objetos de 50MB de volta à thread principal constrói uma segunda cópia e dobra o pico. A fronteira correta é analisar e achatar dentro do Worker, retornar apenas as linhas que o renderizador precisa:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

Se os dados das linhas ainda forem grandes, comprima as linhas em arrays paralelos (caminho, tipo, offset de valor), coloque-os em um ArrayBuffer e transfira a propriedade para que a thread principal não pague por uma cópia. O custo é escrever seu próprio serializador, o que só vale a pena com milhões de linhas.

A regra para a fronteira é simples: a thread principal deve fazer apenas o trabalho que precisa tocar o DOM. Análise, busca, ordenação, formatação e diff podem todos se mudar. Renderização, rolagem, entrada e foco ficam na thread principal, e cada frame precisa caber no orçamento de trabalho.

9. Métricas e ordem de implementação

A otimização não pode parar em "parece mais rápido". O conjunto a seguir pode ir direto para uma lista de verificação de aceitação:

Métrica Meta Como medir
Primeiro frame visível Abaixo de 300ms performance.now() da abertura até a primeira pintura
Tarefas longas Nenhuma acima de 50ms PerformanceObserver observando longtask
Tempo de frame de rolagem Estável abaixo de 16,7ms Gravação no DevTools Performance
Elementos de DOM no viewport Abaixo de 2.000 document.querySelectorAll('*').length
Resposta a expandir / recolher Abaixo de 50ms Timestamps em volta da interação
Busca em mais de 100.000 linhas Abaixo de 300ms, dentro de um Worker Tempo no Worker, retorne a duração
Heap residente Abaixo de 3x o tamanho do texto Heap snapshot no DevTools Memory

Meça com um arquivo real em vez de objetos pequenos gerados uniformemente; a forma dos dados importa, e strings longas, aninhamento profundo e arrays muito largos produzem curvas diferentes. Para o livro-caixa do grafo de objetos em si, veja A contabilidade de memória do JSON.

Implemente nesta ordem:

  1. Meça uma linha de base: tempo de primeira renderização, contagem de nós, tempo de frame de rolagem;
  2. Recolha por padrão: expanda dois níveis, mostre tipo e contagem nos resumos;
  3. Lista virtual: altura de linha fixa, chaves estáveis, viewport mais um pequeno buffer;
  4. Mova o cálculo: análise, busca e achatamento para um Worker;
  5. Restrinja o realce: estilize apenas as linhas visíveis, evite repintar a árvore inteira;
  6. Adicione guardrails: acima de um limite, ofereça "mostrar as primeiras N linhas" ou exija um filtro de busca.

Cada etapa compartilha um objetivo: reduzir os nós e objetos que precisam existir no mesmo momento, em vez de pedir ao navegador que trabalhe mais neles. Todo o histórico da estratégia em camadas está em Análise, renderização e streaming de JSON grande.

A rolagem virtual resolve o engasgo em tempo de análise?

Não. A rolagem virtual só reduz nós de DOM. Se a análise ainda acontece na thread principal, a página ainda engasga. Mova a análise para um Worker para documentos acima de 1MB e trate os dois problemas separadamente.

Tudo está expandido e ainda engasga. O que está errado?

Ou a lista virtual não está realmente ativa, ou cada expansão revarre a árvore inteira. Verifique se as linhas fora da tela ainda criam DOM e se a expansão recalcula apenas a subárvore afetada.

CodeMirror ou Monaco para um visualizador JSON?

Prefira o CodeMirror 6 para somente leitura, arquivos grandes e realce local; seu carregamento modular e sob demanda é mais leve. Escolha o Monaco quando precisar de autocompletar, diagnósticos e edição multi-cursor. Com qualquer um dos dois, evite montar uma visão em árvore e um editor para o mesmo documento ao mesmo tempo.

Por que a posição de rolagem fica errada após um salto de busca?

Normalmente porque a visão rolou antes de expandir. Expanda todos os ancestrais primeiro, depois defina a posição de rolagem a partir do novo índice de linha e destaque por último. Chaveie as linhas por caminho em vez de índice para que expandir não desloque todos os índices.

Perguntas frequentes

Como descubro de qual etapa vem o engasgo?

Divida "lento" em análise e construção de objetos, criação de nós de DOM, layout e pintura, e resposta à interação, e cronometre cada etapa com performance.now() da abertura até a primeira pintura. Depois observe longtask com PerformanceObserver — qualquer tarefa acima de 50ms perde quadros, e uma gravação no DevTools Performance mostra se o tempo de frame de rolagem permanece abaixo de 16,7ms.

Por que renderizar a árvore inteira de uma vez deixa a tela em branco?

Um documento de 5MB tem cerca de 125.000 linhas, e uma visão em árvore normalmente transforma cada linha em 3 a 6 nós de DOM — aproximadamente 500.000 elementos de uma vez. Cada elemento também carrega regras de estilo, uma caixa de layout e instruções de pintura, então várias tarefas de thread principal acima de 50ms em sequência fazem a página parecer morta. Nós escondidos por um pai recolhido continuam custando trabalho de layout, então "renderizar tudo e depois esconder" não é uma otimização.

Devo enviar um grafo de objetos de 50MB de volta do Worker?

Não. postMessage usa clonagem estruturada, então o lado receptor constrói uma segunda cópia e o pico dobra. A fronteira correta é analisar e achatar dentro do Worker e retornar apenas as linhas que o renderizador precisa; com milhões de linhas você pode empacotar arrays paralelos (caminho, tipo, offset de valor) em um ArrayBuffer e transferir a propriedade.