Парсинг, отрисовка и потоковая обработка большого 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создаёт новый массив, что тоже дорого при большом числе элементов;- Реактивные прокси фреймворков (Vue
reactive) проксируют большие объекты рекурсивно, и накладные расходы могут быть выше самого парсинга.
Практический совет: держите «сырые данные» как обычный неизменяемый объект и не оборачивайте его в реактивность; вычисляйте и кешируйте производные данные, которые нужно отображать, отдельно.
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, когда нужно
Парсинг, форматирование и сравнение всего больше 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, а форматирование происходит только для отображения.