Parsowanie, renderowanie i strumieniowanie dużego JSON
Podsumowanie: Parsowanie często nie jest wąskim gardłem; renderowanie i kopiowanie są. Optymalizuj wzdłuż czterech warstw „parsowanie → przejście → renderowanie → transport”, aby twoja praca nie poszła na marne.

Najważniejsze: najpierw zmierz osobno parsowanie, przejście, renderowanie i transport, a następnie pracuj w tej kolejności — ogranicz kopie, rozwijaj na żądanie z wirtualnym przewijaniem, przenieś pracę do Workera, przejdź na strumieniowanie, a dopiero potem zmień format. Strumieniowanie jest warte zachodu tylko wtedy, gdy plik nie mieści się w pamięci albo potrzebujesz małego podzbioru jego pól.
Wniosek „JSON.parse jest wolny” jest błędny w ośmiu przypadkach na dziesięć. Naprawdę wolne jest to, co robisz po parsowaniu.
1. Najpierw zmierz, potem optymalizuj
Dla tego samego pliku JSON o rozmiarze 10 MB czas spędzony na różnych etapach różni się o rzędy wielkości:
| Etap | Typowy czas | Główny koszt |
|---|---|---|
JSON.parse (natywny) |
Od dziesiątek do nieco ponad stu milisekund | Pojedyncze liniowe skanowanie plus budowa obiektów |
Głęboka kopia (structuredClone / rekurencyjna) |
Możliwe 1–3 razy czas parsowania | Odbudowa całego grafu obiektów |
| Głębokie porównanie / diff | Możliwe kolejne podwojenie | Przejście rekurencyjne plus porównanie łańcuchów |
| Renderowanie do drzewa DOM | Zawieszenie, gdy pojawią się dziesiątki tysięcy węzłów | Układ i reflow |
| Konkatenacja łańcuchów, potem serializacja | Proporcjonalnie do rozmiaru wyniku | Podwojenie pamięci |
Więc zmierz, zanim zaczniesz działać: w przeglądarce owij każdy etap przez performance.now() albo użyj console.time;
w Node użyj process.memoryUsage(), aby obserwować wzrost sterty. Bez baseline nie ma optymalizacji.
2. Warstwa parsowania: trzy strategie
2.1 Pełne parsowanie (domyślny wybór)
const data = JSON.parse(text);
Natywna implementacja wykonuje skanowanie i budowę obiektów w C++, więc biblioteka czysto JS raczej jej nie pobije. Używaj jej, dopóki obowiązują trzy warunki:
- Tekst całkowicie mieści się w pamięci (zwykle < 100 MB);
- Po parsowaniu naprawdę potrzebne jest większość pól;
- Stopień niezaufania danych jest kontrolowalny.
2.2 Strumieniowe parsowanie przyrostowe
W parserze w stylu SAX, takim jak stream-json, potrafisz wyszukać docelowe pola bez budowania pełnego drzewa obiektów:
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);
}
Koszty:
- Złożoność kodu istotnie rośnie, a obsługa błędów staje się bardziej zawiła;
- Możliwy jest tylko dostęp sekwencyjny, bez dostępu swobodnego;
- Jest nieprzyjazny wymaganiom wymagającym agregacji globalnej.
Reguła decyzyjna: strumieniowanie jest warte zachodu tylko wtedy, gdy plik jest zbyt duży, by się zmieścił, albo gdy potrzebujesz tylko bardzo małego podzbioru jego pól.
2.3 Zmiana formatu
Jeśli dane to „partia rekordów”, a nie „pojedynczy dokument”, powinieneś od początku używać JSON Lines. Format liniowy zamienia „strumieniowanie” w „czytanie linia po linii”, obniżając złożoność implementacji o rząd wielkości.
3. Warstwa przejścia: jedna kopia mniej wygrywa połowę walki
// 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 };
}
Typowe ukryte kopie:
JSON.parse(JSON.stringify(x))użyte jako „głęboka kopia” — to najbardziej kosztowny rodzaj;{ ...data }kopiuje tylko jeden poziom, ale przy dużym obiekcie nadal kopiuje tablicę referencji wszystkich kluczy najwyższego poziomu;Array.prototype.maptworzy nową tablicę, co też jest kosztowne przy wielu elementach;- Reaktywne proksy frameworka (Vue
reactive) proksują duże obiekty rekurencyjnie, a narzut może przewyższać samo parsowanie.
Praktyczna rada: trzymaj „surowe dane” jako zwykły, niezmienny obiekt i nie owijaj ich reaktywnością; obliczaj i cache’uj osobno dane pochodne, które musisz wyświetlać.
4. Warstwa renderowania: nie wpychaj całego drzewa do DOM
Plik JSON o rozmiarze 5 MB ma około 30 000 do 100 000 węzłów; renderowanie ich wszystkich jako elementów DOM sprawi, że przeglądarka przestanie odpowiadać.
Trzy realne podejścia:
4.1 Rozwijanie na żądanie
Renderuj tylko bezpośrednie dzieci węzła głównego i obliczaj następny poziom dopiero po rozwinięciu węzła. To standardowe podejście przeglądarek drzewa:
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 Wirtualne przewijanie
Gdy rozwinięte węzły przekraczają kilka setek, renderuj tylko kilkadziesiąt w widocznym obszarze i zarezerwuj miejsce dla reszty za pomocą zastępczych wysokości. Jest najskuteczniejsze w widokach listowych (tabele, logi).
4.3 Pokazuj tylko streszczenia
Najpierw przedstaw „struktura + typ + liczba” (na przykład items: Array(12000)); dopiero gdy użytkownik otworzy element, pobierz konkretną wartość.
Ten trik działa najlepiej w scenariuszu „chcę tylko potwierdzić, że pola są poprawne”.
5. Wątek główny: przenieś pracę do Workera, gdy należy
Parsowanie, formatowanie i diffowanie czegokolwiek większego niż 1 MB powinno trafić do Web Workera:
// 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 });
});
}
Uważaj na koszt komunikacji Workerów: postMessage domyślnie używa klonowania strukturalnego, a sklonowanie obiektu 10 MB raz też zajmuje dziesiątki milisekund.
Jeśli potrzebujesz tylko „parsuj i przekaż do wątku głównego”, wykonaj ciężką pracę wewnątrz Workera (parsowanie + przekształcenie + walidacja) i odeślij streszczenie wyniku.
6. Warstwa transportu: optymalizacja rozmiaru
| Technika | Typowy zysk | Koszt |
|---|---|---|
| gzip / brotli | Skompresowany wynik to często 10%–25% oryginalnego rozmiaru | Wymaga wsparcia serwera |
| Usunięcie wcięć i białych znaków | Redukcja 20%–40% | Nieczytelne dla człowieka |
| Skracanie nazw pól | Zauważalny zysk przy głębokim zagnieżdżeniu | Niższa czytelność; wymaga tabeli mapowania |
| Liczby zamiast łańcuchów wyliczeń | Niewielki | Wymaga słownika |
| Paginacja / pola na żądanie | Największy | Przebudowa API |
Dla stron przeznaczonych dla ludzi właściwa kombinacja to: API wysyła zwięzły, skompresowany JSON, a UI formatuje go tylko do wyświetlenia. Wiele witryn narzędziowych domyślnie wysyła do frontendu ponownie sformatowany tekst, dostarczając 30% więcej bajtów za darmo.
7. Jak benchmarkować
/**
* 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)];
}
Trzy zasady dyscypliny:
- Używaj stałej próbki i nigdy nie generuj danych losowo, inaczej liczby nie są porównywalne;
- Uruchamiaj co najmniej 9 razy i bierz medianę; pierwsze uruchomienie zawiera rozgrzewkę JIT i trzeba je odrzucić;
- Zapisz środowisko uruchomieniowe (wersja Node, CPU), inaczej porównania między maszynami są bez znaczenia.
8. Lista kontrolna
- [ ] Baseline istnieje, więc można porównać przed i po;
- [ ] Parsowanie czegokolwiek większego niż 1 MB działa w Workerze;
- [ ] Widoki drzewa rozwijają na żądanie, a listy używają wirtualnego przewijania;
- [ ] Brak bezcelowych głębokich kopii i opakowań reaktywnych;
- [ ] JSON Lines jest używany, gdy dane to „partia rekordów”;
- [ ] API wysyła zwięzły JSON, a formatowanie następuje tylko do wyświetlenia.