JSON Toolkit

Parsing, rendering e streaming di JSON di grandi dimensioni

In sintesi: Il parsing spesso non è il collo di bottiglia; lo sono il rendering e le copie. Ottimizza lungo i quattro strati "parse → traverse → render → transport" così il tuo sforzo non va sprecato.

Intermedio~13 min di letturaPubblicato il 2026-10-30Aggiornato il 2026-10-31
Copertina di Parsing, rendering e streaming di JSON di grandi dimensioni

In sintesi: misura prima separatamente parsing, attraversamento, rendering e trasporto, poi procedi in questo ordine — riduci le copie, espandi su richiesta con scorrimento virtuale, sposta il lavoro in un Worker, passa allo streaming, e solo allora cambia il formato. Lo streaming vale la pena solo quando il file non sta in memoria o quando ti serve un piccolo sottoinsieme dei suoi campi.

La conclusione "JSON.parse è lento" è sbagliata otto volte su dieci. Ciò che è davvero lento è quello che fai dopo il parsing.

1. Misura prima, poi ottimizza

Per lo stesso file JSON da 10 MB, il tempo trascorso nelle diverse fasi differisce di ordini di grandezza:

Fase Durata tipica Costo principale
JSON.parse (nativo) Da decine a poco più di cento millisecondi Una singola scansione lineare più la costruzione degli oggetti
Copia profonda (structuredClone / ricorsiva) Possibilmente 1–3 volte il parsing Ricostruzione dell'intero grafo di oggetti
Confronto profondo / diff Possibilmente il doppio ancora Attraversamento ricorsivo più confronto di stringhe
Rendering in un albero DOM Si blocca una volta che ci sono decine di migliaia di nodi Layout e reflow
Concatenazione di stringhe poi serializzazione Proporzionale alla dimensione dell'output Raddoppia la memoria

Quindi misura prima di agire: nel browser, avvolgi ogni fase con performance.now(), o usa console.time; in Node, usa process.memoryUsage() per osservare la crescita dell'heap. Nessuna baseline, nessuna ottimizzazione.

2. Strato di parsing: tre strategie

2.1 Parsing completo (la scelta predefinita)

const data = JSON.parse(text);

L'implementazione nativa fa la scansione e la costruzione degli oggetti in C++, quindi una libreria in puro JS difficilmente può batterla. Usalo finché valgono queste tre condizioni:

  • Il testo sta interamente in memoria (di solito < 100 MB);
  • Dopo il parsing servono davvero la maggior parte dei campi;
  • Il grado di non fiducia nei dati è gestibile.

2.2 Parsing incrementale in streaming

Con un parser in stile SAX come stream-json, puoi scansionare i campi di destinazione senza costruire un albero di oggetti completo:

import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  // Process one element at a time; memory is independent of element count
  handleItem(value);
}

Costi:

  • La complessità del codice sale significativamente e la gestione degli errori diventa più disordinata;
  • È possibile solo l'accesso sequenziale, senza accesso casuale;
  • È ostile ai requisiti che necessitano di aggregazione globale.

Regola di decisione: lo streaming vale la pena solo quando il file è troppo grande per stare in memoria, o quando ti serve solo un sottoinsieme molto piccolo dei suoi campi.

2.3 Cambiare il formato

Se i dati sono "un lotto di record" invece di "un singolo documento", avresti dovuto usare JSON Lines fin dall'inizio. Un formato delimitato da righe trasforma lo "streaming" in "lettura riga per riga", abbassando la complessità implementativa di un ordine di grandezza.

3. Strato di attraversamento: una copia in meno vince metà della battaglia

// Anti-pattern: deep copying first "for safety"
const safeCopy = structuredClone(bigData);   // Memory doubles immediately

// Good pattern: pass references directly in read-only scenarios
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

Copie nascoste comuni:

  • JSON.parse(JSON.stringify(x)) usato come "copia profonda" — è il tipo più costoso;
  • { ...data } copia solo un livello, ma per un oggetto grande copia comunque l'array di riferimenti di tutte le chiavi di primo livello;
  • Array.prototype.map crea un nuovo array, anch'esso costoso quando gli elementi sono molti;
  • I proxy di reattività dei framework (reactive di Vue) fanno proxy degli oggetti grandi ricorsivamente, e l'overhead può essere superiore al parsing stesso.

Consiglio pratico: mantieni i "dati grezzi" come un oggetto semplice immutabile e non avvolgerli in reattività; calcola e memorizza separatamente i dati derivati che devi mostrare.

4. Strato di rendering: non ficcare l'intero albero nel DOM

Un file JSON da 5 MB ha circa 30.000-100.000 nodi; renderizzarli tutti come elementi DOM farà smettere di rispondere il browser.

Tre approcci praticabili:

4.1 Espansione su richiesta

Renderizza solo i figli diretti del nodo radice, e calcola il livello successivo solo quando il nodo viene espanso. È l'approccio standard per i visualizzatori ad albero:

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * Returns only the currently visible nodes, avoiding building the whole tree at once.
 * @param rootNode Root node
 * @param expandedPaths Set of paths that have been expanded
 * @returns List of visible nodes
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * Recursively collects the child nodes of expanded branches.
   * @param currentNode Current node
   * @param currentPath Current path
   */
  const walk = (currentNode: TreeNode, currentPath: string): void => {
    visibleNodes.push(currentNode);
    if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
      return;
    }
    for (const [childKey, childValue] of Object.entries(currentNode.value)) {
      walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
    }
  };

  walk(rootNode, '');
  return visibleNodes;
}

4.2 Scorrimento virtuale

Una volta che i nodi espansi superano qualche centinaio, renderizza solo le poche dozzine dentro il viewport e riserva spazio per il resto con altezze segnaposto. È più efficace nelle viste in stile lista (tabelle, log).

4.3 Mostrare solo riassunti

Presenta prima "struttura + tipo + conteggio" (per esempio items: Array(12000)); solo quando l'utente apre un elemento recuperi il valore concreto. Questo trucco funziona meglio per lo scenario "voglio solo confermare che i campi siano corretti".

5. Thread principale: sposta il lavoro in un Worker quando devi

Parsing, formattazione e diff di qualsiasi cosa più grande di 1 MB dovrebbero andare in un Web Worker:

// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
  try {
    const parsed = JSON.parse(event.data.text);
    self.postMessage({ ok: true, data: parsed });
  } catch (parseError) {
    self.postMessage({ ok: false, message: (parseError as Error).message });
  }
};
/**
 * Parses JSON inside a Worker so the main thread is not blocked.
 * @param text JSON text
 * @returns Parse result
 */
function parseInWorker(text: string): Promise<unknown> {
  return new Promise((resolvePromise, rejectPromise) => {
    const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (event) => {
      worker.terminate();
      if (event.data.ok) {
        resolvePromise(event.data.data);
      } else {
        rejectPromise(new Error(event.data.message));
      }
    };
    worker.postMessage({ text });
  });
}

Fai attenzione al costo di comunicazione dei Worker: postMessage usa la clonazione strutturata per impostazione predefinita, e clonare una volta un oggetto da 10 MB richiede anch'esso decine di millisecondi. Se tutto ciò che ti serve è "analizzarlo e passarlo al thread principale", fai il lavoro pesante dentro il Worker (parsing + trasformazione + validazione) e invia indietro un riassunto del risultato.

6. Strato di trasporto: ottimizzazione della dimensione

Tecnica Guadagno tipico Costo
gzip / brotli L'output compresso è spesso il 10%-25% della dimensione originale Richiede supporto del server
Rimozione di indentazione e spazi Riduzione del 20%-40% Non leggibile dagli umani
Abbreviazione dei nomi dei campi Guadagni evidenti con annidamento profondo Leggibilità inferiore; richiede una tabella di mapping
Numeri invece di stringhe enum Piccolo Richiede un dizionario
Paginazione / campi su richiesta Il più grande Riprogettazione dell'API

Per le pagine pensate per gli umani, la combinazione corretta è: l'API invia JSON compatto e compresso, e l'interfaccia lo abbellisce solo per la visualizzazione. Molti siti di strumenti inviano di nuovo al frontend il testo abbellito per impostazione predefinita, spedendo il 30% di byte in più per nulla.

7. Come fare benchmark

/**
 * Measures the median duration of a synchronous routine so a single jittery run does not skew the result.
 * @param runOnce Function under test
 * @param repeatCount Number of repetitions
 * @returns Median duration in milliseconds
 */
function measureMedian(runOnce: () => void, repeatCount = 9): number {
  const durations: number[] = [];
  for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
    const startedAt = performance.now();
    runOnce();
    durations.push(performance.now() - startedAt);
  }
  durations.sort((firstValue, secondValue) => firstValue - secondValue);
  return durations[Math.floor(durations.length / 2)];
}

Tre regole di disciplina:

  1. Usa un campione fisso e non generare dati casualmente, altrimenti i numeri non sono confrontabili;
  2. Esegui almeno 9 volte e prendi la mediana; la prima esecuzione include il warm-up del JIT e deve essere scartata;
  3. Registra l'ambiente di runtime (versione di Node, CPU), altrimenti i confronti tra macchine diverse non hanno senso.

8. Lista di controllo

  • [ ] Esistono numeri di baseline così il prima e il dopo possono essere confrontati;
  • [ ] Il parsing di qualsiasi cosa più grande di 1 MB gira in un Worker;
  • [ ] Le viste ad albero si espandono su richiesta e le liste usano lo scorrimento virtuale;
  • [ ] Nessuna copia profonda inutile o wrapping reattivo;
  • [ ] JSON Lines viene usato quando i dati sono "un lotto di record";
  • [ ] L'API invia JSON compatto e la formattazione avviene solo per la visualizzazione.