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.

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.mapcrea un nuovo array, anch'esso costoso quando gli elementi sono molti;- I proxy di reattività dei framework (
reactivedi 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:
- Usa un campione fisso e non generare dati casualmente, altrimenti i numeri non sono confrontabili;
- Esegui almeno 9 volte e prendi la mediana; la prima esecuzione include il warm-up del JIT e deve essere scartata;
- 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.