JSON Toolkit

Парсинг, отрисовка и потоковая обработка большого JSON

Главное: Парсинг часто не является узким местом; отрисовка и копирование являются. Оптимизируйте по четырём слоям «парсинг → обход → отрисовка → передача», чтобы ваши усилия не были потрачены впустую.

Средний~13 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-31
Обложка для парсинга, отрисовки и потоковой обработки большого 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)];
}

Три правила дисциплины:

  1. Используйте фиксированный образец и никогда не генерируйте данные случайно, иначе числа несравнимы;
  2. Запускайте не менее 9 раз и берите медиану; первый прогон включает JIT-разогрев и должен быть отброшен;
  3. Записывайте среду выполнения (версию Node, CPU), иначе кросс-машинные сравнения бессмысленны.

8. Контрольный список

  • [ ] Существуют базовые числа, чтобы можно было сравнить до и после;
  • [ ] Парсинг всего больше 1 МБ выполняется в Worker;
  • [ ] Древовидные представления раскрываются по требованию, а списки используют виртуальную прокрутку;
  • [ ] Нет бессмысленных глубоких копий или реактивных обёрток;
  • [ ] JSON Lines используется, когда данные — «пакет записей»;
  • [ ] API отправляет компактный JSON, а форматирование происходит только для отображения.