Розбір, відображення й потокова обробка великого JSON
Головне: Розбір часто не є вузьким місцем; ним є відображення й копіювання. Оптимізуйте вздовж чотирьох шарів «розбір → обхід → відображення → передача», щоб ваші зусилля не змарнувалися.

Коротко: спершу виміряйте розбір, обхід, відображення й передачу окремо, а потім працюйте в такому порядку — скорочуйте копії, розгортайте на вимогу з віртуальною прокруткою, переносьте роботу в Worker, переходьте на потокову обробку і лише тоді змінюйте формат. Потокова обробка варта того, лише коли файл не вміщується в памʼять або коли потрібна мала підмножина його полів.
Висновок «JSON.parse повільний» неправильний у восьми випадках із десяти. Насправді повільно те, що ви робите після розбору.
1. Спершу виміряйте, потім оптимізуйте
Для того самого файлу JSON на 10 МБ час, витрачений на різних етапах, різниться на порядки:
| Етап | Типова тривалість | Основна ціна |
|---|---|---|
JSON.parse (нативний) |
Від десятків до трохи понад сто мілісекунд | Один лінійний скан плюс побудова обʼєктів |
Глибока копія (structuredClone / рекурсивна) |
Можливо, 1–3 часу розбору | Перебудова всього графа обʼєктів |
| Глибоке порівняння / diff | Можливо, ще вдвічі | Рекурсивний обхід плюс порівняння рядків |
| Відображення в дерево DOM | Зависає, щойно з'являється десятки тисяч вузлів | Компонування й перекомпонування |
| Конкатенація рядків, потім серіалізація | Пропорційно розміру виводу | Подвоює памʼять |
Тож виміряйте, перш ніж діяти: у браузері обгорніть кожен етап через performance.now() або скористайтеся console.time;
у Node скористайтеся process.memoryUsage(), щоб стежити за зростанням купи. Немає базового рівня — немає оптимізації.
2. Шар розбору: три стратегії
2.1 Повний розбір (вибір за замовчуванням)
const data = JSON.parse(text);
Нативна реалізація робить сканування й побудову обʼєктів на C++, тож чиста JS-бібліотека навряд чи її перевершить. Використовуйте його, поки виконуються три умови:
- Текст повністю вміщується в памʼять (зазвичай < 100 МБ);
- Більшість полів справді потрібні після розбору;
- Ступінь недовіреності даних керований.
2.2 Потоковий інкрементальний розбір
З парсером у стилі SAX, як-от stream-json, ви можете висканувати цільові поля, не будуючи повне дерево обʼєктів:
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) {
// Обробляйте один елемент за раз; памʼять не залежить від кількості елементів
handleItem(value);
}
Ціни:
- Складність коду значно зростає, а обробка помилок стає заплутанішою;
- Можливий лише послідовний доступ, без довільного;
- Це недружньо до вимог, що потребують глобальної агрегації.
Правило рішення: потокова обробка варта того, лише коли файл завеликий, щоб уміститися, або коли потрібна лише дуже мала підмножина його полів.
2.3 Змініть формат
Якщо дані — це «пакет записів», а не «один документ», вам слід було використовувати JSON Lines від початку. Формат із розділенням рядків перетворює «потоковість» на «читання рядок за рядком», знижуючи складність реалізації на порядок.
3. Шар обходу: одна копія менше — половина битви
// Антипатерн: спершу глибока копія «заради безпеки»
const safeCopy = structuredClone(bigData); // Памʼять подвоюється одразу
// Добрий шаблон: передавайте посилання безпосередньо в сценаріях лише для читання
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
Поширені приховані копії:
JSON.parse(JSON.stringify(x))як «глибока копія» — це найдорожчий вид;{ ...data }копіює лише один рівень, але для великого обʼєкта він усе ще копіює масив посилань усіх ключів верхнього рівня;Array.prototype.mapстворює новий масив, що теж дорого, коли елементів багато;- Реактивні проксі фреймворків (
reactiveу Vue) проксіюють великі обʼєкти рекурсивно, а накладні витрати можуть бути вищими за сам розбір.
Практична порада: тримайте «сирі дані» як звичайний незмінний обʼєкт і не обгортайте його в реактивність; обчислюйте й кешуйте похідні дані, які треба показати, окремо.
4. Шар відображення: не впихуйте все дерево в DOM
Файл JSON на 5 МБ має приблизно від 30 000 до 100 000 вузлів; відображення їх усіх як DOM-елементів змусить браузер перестати реагувати.
Три робочі підходи:
4.1 Розгортання на вимогу
Відображайте лише безпосередніх дітей кореневого вузла й обчислюйте наступний рівень, лише коли вузол розгорнуто. Це типовий підхід для переглядачів дерев:
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 Віртуальна прокрутка
Щойно розгорнутих вузлів стає більше кількохсот, відображайте лише кілька десятків усередині вікна перегляду й резервуйте місце для решти висотами-заповнювачами. Найефективніша в спископодібних виглядах (таблиці, логи).
4.3 Показуйте лише підсумки
Спершу подавайте «структура + тип + кількість» (наприклад items: Array(12000)); лише коли користувач відкриває елемент, отримуйте конкретне значення.
Цей трюк найкраще працює для сценарію «я хочу лише підтвердити, що поля правильні».
5. Головний потік: переносьте роботу в Worker, коли слід
Розбір, форматування й diff будь-чого більшого за 1 МБ мають іти в 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 });
});
}
Майте на увазі ціну комунікації Worker: postMessage за замовчуванням використовує структуроване клонування, а клонування обʼєкта на 10 МБ один раз теж займає десятки мілісекунд.
Якщо все, що потрібно, — «розібрати й передати в головний потік», робіть важку роботу всередині Worker (розбір + перетворення + перевірка) і надсилайте назад підсумок результату.
6. Шар передачі: оптимізація розміру
| Техніка | Типовий виграш | Ціна |
|---|---|---|
| gzip / brotli | Стиснений вивід часто 10–25% від оригінального розміру | Потрібна підтримка сервера |
| Видалення відступів і пробільних символів | Скорочення на 20–40% | Не читабельний для людини |
| Скорочення назв полів | Помітний виграш при глибокій вкладеності | Нижча читабельність; потрібна таблиця відображення |
| Числа замість рядків переліків | Малий | Потрібен словник |
| Пагінація / поля на вимогу | Найбільший | Переробка API |
Для сторінок, призначених людям, правильна комбінація така: API надсилає компактний стиснений JSON, а UI прикрашає його лише для показу. Багато утилітарних сайтів за замовчуванням знову надсилають на фронтенд прикрашений текст, відправляючи на 30% більше байтів дарма.
7. Як вимірювати
/**
* 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)];
}
Три правила дисципліни:
- Використовуйте фіксований зразок і ніколи не генеруйте дані випадково, інакше числа непорівнювані;
- Запускайте принаймні 9 разів і беріть медіану; перший запуск включає розігрів JIT і його треба відкинути;
- Записуйте середовище виконання (версію Node, CPU), інакше порівняння між машинами безглузді.
8. Чек-лист
- [ ] Базові числа існують, щоб можна було порівняти до і після;
- [ ] Розбір будь-чого більшого за 1 МБ відбувається в Worker;
- [ ] Деревні вигляди розгортаються на вимогу, а списки використовують віртуальну прокрутку;
- [ ] Жодних безглуздих глибоких копій чи реактивного обгортання;
- [ ] Використовується JSON Lines, коли дані — це «пакет записів»;
- [ ] API надсилає компактний JSON, а форматування відбувається лише для показу.