JSON Toolkit

Perché gli alberi JSON grandi vanno a scatti: scorrimento virtuale e strategie di compressione

In sintesi: Gli scatti iniziano quando vengono creati i nodi DOM, non quando scorri. Copre rendering con compressione prima, liste virtuali, evidenziazione della ricerca e confini dei Worker, con metriche.

Avanzato~10 min di letturaPubblicato il 2026-10-31Aggiornato il 2026-10-31
Scorrimento virtuale e strategia di compressione per un visualizzatore JSON

In sintesi: scomponi "lento" in parsing, creazione dei nodi, layout e paint, e interazione, poi misura ogni fase. Implementa in questo ordine — compressione per impostazione predefinita, aggiungi una lista virtuale, sposta il calcolo in un Worker e limita l'evidenziazione alle righe visibili. Saltare direttamente a una lista virtuale senza compressione riduce solo l'insieme visibile; il ricalcolo dell'intero albero ti costa comunque.

Lo scatto di solito deriva dalle centinaia di migliaia di nodi DOM che hai creato per quei 5 MB, non dai 5 MB stessi.

1. Scomporre "lento" in quattro fasi

Quando un utente dice "si blocca all'apertura", il problema può vivere in quattro fasi completamente diverse, ciascuna con un rimedio diverso:

Fase Trigger tipico Soglia percepibile Costo dominante
Parsing e costruzione oggetti Apertura di un file, incolla del testo Qualsiasi task oltre 50 ms Scansione delle stringhe, costruzione del grafo di oggetti
Creazione dei nodi DOM Rendering dell'intero albero in una volta Decine di migliaia di nodi Allocazione di elementi, nodi di testo, oggetti di stile
Layout e paint Prima visualizzazione, scorrimento Qualsiasi frame oltre 16,7 ms Geometria, reflow, repaint
Risposta all'interazione Espandi, comprimi, cerca Oltre 100 ms sembra in ritardo Ri-appiattimento e ri-rendering

Il thread principale del browser fa una cosa alla volta. Se qualsiasi fase lo occupa per più di 50 ms, scorrimento e digitazione perdono frame; diversi task di questo tipo di fila e la pagina sembra morta. La prima ottimizzazione quindi non è requestAnimationFrame; è misurare quale fase sta davvero spendendo il tempo.

2. Il numero di nodi è il vero nemico

I visualizzatori ad albero moltiplicano i conteggi dei nodi per design. Una singola riga JSON di solito diventa "indentazione + chiave + due punti + valore + virgola + chevron", quindi una stima prudente è 3-6 nodi DOM per riga:

Dimensione del file Righe approssimative Nodi DOM della vista ad albero (stima) Come appare il rendering in un colpo
100 KB 2.500 10.000 Di solito tollerabile
1 MB 25.000 100.000 Chiaramente a scatti, possibilmente secondi di schermo bianco
5 MB 125.000 500.000 Molto probabilmente non risponde
20 MB 500.000 Oltre 2 milioni Collassano sia memoria sia layout

Ogni elemento è più di qualche dozzina di byte di oggetto: porta regole di stile, un box di layout e istruzioni di paint. Una volta creato, un nodo costa comunque lavoro di layout e stile anche se viene poi nascosto da un genitore compresso, purché non sia stato davvero rimosso. "Renderizza tutto, poi nascondilo" non è un'ottimizzazione; rimanda solo il costo al reflow successivo.

3. Comprimi prima, virtualizza dopo

Lo scorrimento virtuale risolve "troppe righe visibili". La compressione risolve "non dovremmo avere affatto bisogno di vedere così tante righe". L'ordine conta: per un documento di un milione di righe espanso per impostazione predefinita, perfino una lista virtuale perfetta deve ricalcolare l'intero albero quando l'utente fa clic su "comprimi tutto".

Una strategia di compressione praticabile:

  • Espandi solo i primi due livelli per impostazione predefinita. La radice e i suoi campi immediati sono visibili; i livelli più profondi mostrano un riassunto come items: Array(12000);
  • Metti tipo e conteggio nel riassunto. Gli utenti che controllano "i nomi dei campi sono corretti" raramente hanno bisogno di diecimila record;
  • Memorizza in cache le righe per sottoalbero. Ogni nodo espanso possiede il proprio array di righe appiattite; comprimerlo lo scarta, ed espanderlo ricalcola solo quel sottoalbero;
  • Non riscansionare mai l'intero albero a ogni toggle. Se l'espansione avviene alla riga 3.000, sostituisci solo quell'intervallo e mantieni intatte le righe circostanti.
/**
 * Insert a subtree's rows after its parent instead of re-flattening the whole tree.
 * @param rows Current visible row array
 * @param parentIndex Index of the parent row
 * @param childRows Flattened rows of the subtree
 * @returns A new visible row array
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

Il pattern da evitare è "una espansione, un relayout completo". Ripercorrere il grafo di oggetti completo a ogni clic fa crescere il costo linearmente con il documento. Con la cache per sottoalbero, una singola espansione costa solo quanto quel sottoalbero.

4. Liste virtuali: renderizza solo il viewport

Una volta che la compressione ha ridotto il numero di righe, il resto può ancora essere di decine di migliaia di righe. Una lista virtuale imposta l'altezza del contenitore a "righe totali x altezza fissa di riga" e crea elementi solo per il viewport più un piccolo buffer sopra e sotto.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Compute the row range that the viewport should render.
 * @param rows All visible rows
 * @param scrollTop Current scroll offset of the container
 * @param viewportHeight Visible height of the container
 * @returns Rows to render, the start index, and the vertical offset
 */
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,
  };
}

Vincoli non negoziabili:

  • L'altezza delle righe deve essere fissa. Solo allora scrollTop / ROW_HEIGHT ti dà direttamente l'indice. Consentire righe che vanno a capo ti costringe a misurare e memorizzare ogni altezza, il che aggiunge un ordine di grandezza in complessità e instabilità;
  • Offset con transform o posizionamento assoluto. Non spingere il contenuto verso il basso con un elemento spacer, altrimenti ogni scorrimento muta il layout;
  • Usa chiavi stabili. Chiavi le righe per percorso, non per indice dell'array, altrimenti espandere una riga rimonta ogni riga successiva;
  • Fai aritmetica solo nel gestore dello scorrimento. Rimanda il rendering a requestAnimationFrame e proteggiti dalla pianificazione duplicata;
  • Mantieni piccolo il buffer. Cinque-dieci righe su ciascun lato bastano; raddoppiare il buffer raddoppia nodi DOM e lavoro di stile.

Con un viewport di 800 pixel e righe di 24 pixel, sono visibili circa 34 righe, e anche con il buffer ne renderizzi circa cinquanta. Il documento sottostante può contenere centomila righe mentre il DOM contiene costantemente poche centinaia di elementi, mantenendo piatto il costo di layout.

5. Comprimi, espandi e ancoraggio dello scorrimento

Una volta che l'indice delle righe diventa l'unica coordinata, alcuni dettagli dell'interazione iniziano a rompersi:

  1. Preserva l'ancora visiva quando comprimi sopra il viewport. Le righe sottostanti si spostano verso l'alto; se scrollTop non viene regolato nello stesso commit, il contenuto sembra saltare. Registra il percorso della riga ancora e ripristina la posizione di scorrimento usando il suo nuovo indice.
  2. Espandi gli antenati prima di saltare. Quando una ricerca colpisce un campo profondo, espandi prima ogni genitore, poi calcola l'indice della riga di destinazione, poi scorri ed evidenzia. Salta il primo passo e la riga di destinazione non è nemmeno nell'array visibile.
  3. Guida la navigazione da tastiera dalle righe visibili. Su e giù spostano l'indice delle righe; sinistra e destra espandono e comprimono. Non percorrere mai l'intero albero dentro un gestore di tasti.
  4. Raggruppa gli aggiornamenti in un frame. Quando espandi diversi livelli di seguito, unisci le modifiche all'array e renderizza una volta, altrimenti ogni livello innesca un reflow completo.

6. Ricerca ed evidenziazione: calcola una volta, mostra poco

La ricerca è la trappola di prestazioni più comunemente trascurata in un visualizzatore ad albero. Se ogni tasto percorre l'intero grafo di oggetti e ricostruisce l'elenco dei risultati, un documento da centomila nodi si blocca all'istante.

Un approccio a strati:

  • Fai il debounce dell'input di 150-250 ms e tagga ogni ricerca con un numero di sequenza così i risultati obsoleti vengono scartati;
  • Scansiona in un Worker. Percorrere un grafo di oggetti è puro calcolo senza dipendenza dal DOM, quindi non dovrebbe bloccare la digitazione;
  • Memorizza solo percorsi e indici delle righe. Non copiare ogni nodo corrispondente né pre-costruire HTML evidenziato;
  • Evidenzia solo le righe visibili. Il viewport contiene poche dozzine di righe; applica gli stili per percorso corrispondente al momento del rendering;
  • Riutilizza "espandi gli antenati, poi localizza l'indice" per i salti, lo stesso percorso usato dal clic su un chevron.

Un dettaglio di sicurezza: chiavi e valori in un visualizzatore ad albero sono dati dell'utente. Costruire HTML evidenziato da essi richiede escaping, o una stringa che contiene markup diventa XSS. Un visualizzatore JSON non ha bisogno di rich text, quindi nodi di testo più nomi di classi sono sia più sicuri sia più veloci.

7. Evidenziazione sintattica: CodeMirror e Monaco sono strumenti diversi

Una vista ad albero e un editor di testo sono modelli di rendering diversi, quindi "quale è più veloce" non è un confronto diretto:

Dimensione CodeMirror 6 Monaco Editor
Posizionamento Core modulare dell'editor Editor IDE completo
Rendering Basato sul viewport Basato sul viewport, ma modelli e decorazioni sono più pesanti
Parsing Parsing incrementale Lezer sugli intervalli modificati Tokenizer Monarch, riga per riga
File grandi Dipende dalle estensioni; leggero per impostazione predefinita Completo di funzionalità, memoria e costo di avvio più alti
Folding e ricerca Estensioni separate, caricate su richiesta Integrati, con overhead permanente
Trappola tipica Troppe estensioni ripristinano il costo Minimappa, sticky scroll e decorazioni impilate

Due conclusioni pratiche:

  • Una vista ad albero non ha bisogno di un tokenizer completo. Dai a chiavi, stringhe, numeri e booleani un colore ciascuno con nodi di testo semplici e classi CSS; costa un ordine di grandezza meno dell'esecuzione dell'evidenziazione sintattica;
  • Non montare una vista ad albero e un editor contemporaneamente. Mantenere due modelli per un documento raddoppia la memoria; distruggi la vecchia vista invece di nasconderla.

Se un editor appartiene davvero al visualizzatore, CodeMirror 6 si adatta a "sola lettura, file grandi, evidenziazione locale", mentre Monaco si adatta a completamento, diagnostica e editing multi-cursore. Misura il tempo di prima visualizzazione e la memoria residente prima di leggere l'elenco delle funzionalità. Per un confronto più approfondito di editor e strumenti CLI, vedi Editor, strumenti da riga di comando e automazione.

8. Confini dei Worker: sposta parsing e costruzione delle righe fuori dal thread principale

L'altra metà degli scatti di rendering è il parsing sul thread principale. Per JSON oltre 1 MB, parsing, formattazione e costruzione delle righe appiattite appartengono a un Web Worker; il thread principale riceve solo le righe e dipinge il viewport.

I Worker non sono gratuiti: postMessage usa la clonazione strutturata, quindi inviare un grafo di oggetti da 50 MB al thread principale costruisce una seconda copia e raddoppia il picco. Il confine corretto è analizzare e appiattire dentro il Worker, restituire solo le righe di cui il renderer ha bisogno:

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

worker.postMessage({ text });

Se i dati delle righe sono ancora grandi, comprimi le righe in array paralleli (percorso, tipo, offset del valore), mettili in un ArrayBuffer e trasferisci la proprietà così il thread principale non paga una copia. Il costo è scrivere il tuo serializzatore, che vale la pena solo con milioni di righe.

La regola per il confine è semplice: il thread principale dovrebbe fare solo il lavoro che deve toccare il DOM. Parsing, ricerca, ordinamento, formattazione e diff possono tutti spostarsi altrove. Rendering, scorrimento, input e focus restano sul thread principale, e ogni frame deve rientrare nel budget di lavoro.

9. Metriche e ordine di implementazione

L'ottimizzazione non può fermarsi a "sembra più veloce". L'insieme seguente può andare direttamente in una lista di accettazione:

Metrica Obiettivo Come misurarla
Primo frame visibile Sotto 300 ms performance.now() dall'apertura al primo paint
Task lunghi Nessuno oltre 50 ms PerformanceObserver che osserva longtask
Tempo frame di scorrimento Stabile sotto 16,7 ms Registrazione Performance di DevTools
Elementi DOM nel viewport Sotto 2.000 document.querySelectorAll('*').length
Risposta espandi / comprimi Sotto 50 ms Timestamp attorno all'interazione
Ricerca su oltre 100.000 righe Sotto 300 ms, dentro un Worker Tempo nel Worker, che restituisce la durata
Heap residente Sotto 3x la dimensione del testo Heap snapshot del pannello Memory di DevTools

Misura con un file reale invece che con piccoli oggetti generati uniformemente; la forma dei dati conta, e stringhe lunghe, annidamento profondo e array molto larghi producono tutti curve diverse. Per il bilancio del grafo di oggetti stesso, vedi Il bilancio di memoria di JSON.

Implementa in questo ordine:

  1. Misura una baseline: tempo di primo rendering, numero di nodi, tempo frame di scorrimento;
  2. Comprimi per impostazione predefinita: espandi due livelli, mostra tipo e conteggio nei riassunti;
  3. Lista virtuale: altezza fissa delle righe, chiavi stabili, viewport più un piccolo buffer;
  4. Sposta il calcolo: parsing, ricerca e appiattimento in un Worker;
  5. Limita l'evidenziazione: stila solo le righe visibili, evita di ridipingere tutto l'albero;
  6. Aggiungi guardrail: oltre una soglia, offri "mostra le prime N righe" o richiedi un filtro di ricerca.

Ogni passo condivide un obiettivo: ridurre i nodi e gli oggetti che devono esistere nello stesso momento, invece di chiedere al browser di lavorarci di più. Il background completo sulla strategia a strati è in Parsing, rendering e streaming di JSON di grandi dimensioni.

Lo scorrimento virtuale può risolvere gli scatti al momento del parsing?

No. Lo scorrimento virtuale riduce solo i nodi DOM. Se il parsing avviene ancora sul thread principale, la pagina va comunque a scatti. Sposta il parsing in un Worker per i documenti oltre 1 MB e tratta i due problemi separatamente.

Tutto è espanso e va ancora a scatti. Cosa c'è che non va?

O la lista virtuale non è davvero attiva, oppure ogni espansione riscansiona l'intero albero. Controlla se le righe fuori schermo creano comunque DOM, e se l'espansione ricalcola solo il sottoalbero interessato.

CodeMirror o Monaco per un visualizzatore JSON?

Preferisci CodeMirror 6 per sola lettura, file grandi ed evidenziazione locale; il suo caricamento modulare e su richiesta è più leggero. Scegli Monaco quando ti servono completamento, diagnostica ed editing multi-cursore. Con entrambi, evita di montare una vista ad albero e un editor per lo stesso documento contemporaneamente.

Perché la posizione di scorrimento è sbagliata dopo un salto di ricerca?

Di solito perché la vista ha scorso prima di espandere. Espandi prima tutti gli antenati, poi imposta la posizione di scorrimento dal nuovo indice delle righe, ed evidenzia per ultimo. Chiavi le righe per percorso invece che per indice, così l'espansione non sposta ogni indice.

FAQ

Come scopro da quale fase viene lo scatto?

Scomponi "lento" in parsing e costruzione oggetti, creazione dei nodi DOM, layout e paint, e risposta all'interazione, e cronometra ogni fase con performance.now() dall'apertura al primo paint. Poi osserva longtask con PerformanceObserver — qualsiasi task oltre 50 ms perde frame, e una registrazione Performance di DevTools mostra se il tempo frame di scorrimento resta sotto 16,7 ms.

Perché renderizzare tutto l'albero in una volta rende lo schermo bianco?

Un documento da 5 MB è di circa 125.000 righe, e una vista ad albero di solito trasforma ogni riga in 3-6 nodi DOM — circa 500.000 elementi in una volta. Ogni elemento porta anche regole di stile, un box di layout e istruzioni di paint, quindi diversi task del thread principale oltre 50 ms di fila fanno sembrare la pagina morta. I nodi nascosti da un genitore compresso continuano a costare lavoro di layout, quindi "renderizza tutto, poi nascondilo" non è un'ottimizzazione.

Devo restituire un grafo di oggetti da 50 MB dal Worker?

No. postMessage usa la clonazione strutturata, quindi il lato ricevente costruisce una seconda copia e il picco raddoppia. Il confine corretto è analizzare e appiattire dentro il Worker e restituire solo le righe di cui il renderer ha bisogno; con milioni di righe puoi impacchettare array paralleli (percorso, tipo, offset del valore) in un ArrayBuffer e trasferirne la proprietà.