JSON Toolkit

Почему большие деревья JSON подвисают: виртуальная прокрутка и стратегии свёртывания

Главное: Подвисание начинается при создании узлов DOM, а не при прокрутке. Рассматривает отрисовку со свёрнутостью по умолчанию, виртуальные списки, подсветку поиска и границы Worker, с метриками.

Продвинутый~10 мин чтенияОпубликовано 2026-10-31Обновлено 2026-10-31
Виртуальная прокрутка и стратегия свёртывания для просмотрщика JSON

Главное: разделите «медленно» на парсинг, создание узлов, раскладку и отрисовку и взаимодействие, затем измерьте каждый этап. Реализуйте в этом порядке — свёрнутость по умолчанию, добавьте виртуальный список, перенесите вычисления в Worker и ограничьте подсветку видимыми строками. Прыжок сразу к виртуальному списку без свёртывания лишь уменьшает видимый набор; пересчёт всего дерева всё равно стоит вам.

Подвисание обычно происходит от сотен тысяч узлов DOM, созданных для этих 5 МБ, а не от самих 5 МБ.

1. Разделите «медленно» на четыре этапа

Когда пользователь говорит «зависает при открытии», проблема может жить в четырёх совершенно разных этапах, у каждого своё средство:

Этап Типичный триггер Воспринимаемый порог Доминирующая стоимость
Парсинг и построение объектов Открытие файла, вставка текста Любая задача больше 50 мс Сканирование строк, построение графа объектов
Создание узлов DOM Отрисовка всего дерева сразу Десятки тысяч узлов Аллокация элементов, текстовых узлов, объектов стилей
Раскладка и отрисовка Первое отображение, прокрутка Любой кадр больше 16.7 мс Геометрия, перерасчёт, перерисовка
Отклик на взаимодействие Раскрытие, свёртывание, поиск Больше 100 мс ощущается как задержка Повторное уплощение и повторная отрисовка

Главный поток браузера делает одно за раз. Если любой этап занимает его больше 50 мс, прокрутка и ввод теряют кадры; несколько таких задач подряд, и страница выглядит мёртвой. Поэтому первая оптимизация — не requestAnimationFrame; это измерение того, какой этап фактически тратит время.

2. Число узлов — настоящий враг

Просмотрщики деревьев умножают число узлов по замыслу. Одна строка JSON обычно становится «отступ + ключ + двоеточие + значение + запятая + шеврон», поэтому консервативная оценка — 3–6 узлов DOM на строку:

Размер файла Примерно строк Узлы DOM дерева (оценка) Как выглядит однократная отрисовка
100 КБ 2,500 10,000 Обычно терпимо
1 МБ 25,000 100,000 Явно подвисает, возможно секунды пустого экрана
5 МБ 125,000 500,000 Очень вероятно не отвечает
20 МБ 500,000 Более 2 миллионов И память, и раскладка рушатся

Каждый элемент — больше, чем пара дюжин байтов объекта: он несёт правила стилей, бокс раскладки и инструкции отрисовки. После создания узел всё равно стоит работы раскладки и стилей, даже если позже скрыт свёрнутым родителем, пока он фактически не удалён. «Отрисовать всё, затем скрыть» — не оптимизация; это лишь откладывает стоимость до следующего перерасчёта.

3. Сначала свёртывание, потом виртуализация

Виртуальная прокрутка решает «слишком много видимых строк». Свёртывание решает «нам вообще не следует видеть столько строк». Порядок важен: для документа в миллион строк, раскрытого по умолчанию, даже идеальный виртуальный список должен пересчитать всё дерево, когда пользователь нажимает «свернуть всё».

Рабочая стратегия свёртывания:

  • Раскрывайте по умолчанию только первые два уровня. Корень и его непосредственные поля видны; более глубокие уровни показывают сводку вроде items: Array(12000);
  • Помещайте тип и количество в сводку. Пользователям, проверяющим «правильны ли имена полей», редко нужны десять тысяч записей;
  • Кешируйте строки на поддерево. Каждый раскрытый узел владеет своим уплощённым массивом строк; свёртывание его отбрасывает, а раскрытие пересчитывает только это поддерево;
  • Никогда не пересканируйте всё дерево при каждом переключении. Если раскрытие происходит на строке 3,000, заменяйте только этот диапазон и сохраняйте окружающие строки нетронутыми.
/**
 * Insert a subtree's rows after its parent instead of re-flattening the whole tree.
 * @param rows Current visible row array
 * @param parentIndex Index of the parent row
 * @param childRows Flattened rows of the subtree
 * @returns A new visible row array
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

Шаблон, которого следует избегать, — «одно раскрытие, одна полная перекомпоновка». Повторный обход полного графа объектов при каждом клике делает стоимость линейно растущей с документом. С кешированием на поддерево одно раскрытие стоит ровно столько, сколько это поддерево.

4. Виртуальные списки: отрисовывайте только область просмотра

После того как свёртывание уменьшило число строк, остаток всё ещё может быть десятками тысяч строк. Виртуальный список устанавливает высоту контейнера в «общее число строк x фиксированная высота строки» и создаёт элементы только для области просмотра плюс небольшой буфер сверху и снизу.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Compute the row range that the viewport should render.
 * @param rows All visible rows
 * @param scrollTop Current scroll offset of the container
 * @param viewportHeight Visible height of the container
 * @returns Rows to render, the start index, and the vertical offset
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

Незыблемые ограничения:

  • Высота строки должна быть фиксированной. Только тогда scrollTop / ROW_HEIGHT даёт индекс напрямую. Разрешение переносимых строк заставляет измерять и кешировать каждую высоту, что добавляет порядок величины сложности и дрожания;
  • Смещайте через transform или абсолютное позиционирование. Не сдвигайте содержимое вниз элементом-распоркой, иначе каждая прокрутка мутирует раскладку;
  • Используйте стабильные ключи. Ключайте строки по пути, а не по индексу массива, иначе раскрытие одной строки перемонтирует каждую строку после неё;
  • Выполняйте арифметику только в обработчике прокрутки. Откладывайте отрисовку до requestAnimationFrame и защищайтесь от дублирующего планирования;
  • Держите буфер маленьким. Пяти-десяти строк с каждой стороны достаточно; удвоение буфера удваивает узлы DOM и работу стилей.

С областью просмотра в 800 пикселей и строками по 24 пикселя видны примерно 34 строки, и даже с буфером вы отрисовываете около пятидесяти. Подлежащий документ может держать сто тысяч строк, пока DOM стабильно держит несколько сотен элементов, сохраняя стоимость раскладки плоской.

5. Свёртывание, раскрытие и привязка прокрутки

Когда индекс строки становится единственной координатой, несколько деталей взаимодействия начинают ломаться:

  1. Сохраняйте визуальный якорь при свёртывании выше области просмотра. Строки ниже смещаются вверх; если scrollTop не поправлен в том же коммите, содержимое выглядит прыгающим. Запишите путь строки-якоря и восстановите позицию прокрутки по её новому индексу.
  2. Раскрывайте предков перед прыжком. Когда поиск попадает на глубокое поле, сначала раскройте каждого родителя, затем вычислите индекс целевой строки, затем прокрутите и подсветите. Пропустите первый шаг, и целевой строки даже нет в видимом массиве.
  3. Управляйте навигацией с клавиатуры из видимых строк. Вверх и вниз двигают индекс строки; влево и вправо раскрывают и свёртывают. Никогда не обходите всё дерево внутри обработчика клавиш.
  4. Пакетируйте обновления в один кадр. При раскрытии нескольких уровней подряд объединяйте изменения массива и отрисовывайте один раз, иначе каждый уровень вызывает полный перерасчёт.

6. Поиск и подсветка: вычисляйте один раз, отображайте немного

Поиск — самая часто упускаемая ловушка производительности в просмотрщике деревьев. Если каждое нажатие клавиши обходит весь граф объектов и перестраивает список результатов, документ в сто тысяч узлов зависнет мгновенно.

Слоистый подход:

  • Дебаунсите ввод на 150–250 мс и помечайте каждый поиск порядковым номером, чтобы устаревшие результаты отбрасывались;
  • Сканируйте в Worker. Обход графа объектов — чистое вычисление без зависимости от DOM, поэтому он не должен блокировать ввод;
  • Храните только пути и индексы строк. Не копируйте каждый совпавший узел и не предсобирайте подсвеченный HTML;
  • Подсвечивайте только видимые строки. Область просмотра держит несколько десятков строк; применяйте стили по совпавшему пути во время отрисовки;
  • Переиспользуйте «раскрыть предков, затем найти индекс» для прыжков — тот же путь, что используется при клике на шеврон.

Одна деталь безопасности: ключи и значения в просмотрщике деревьев — пользовательские данные. Построение подсвеченного HTML из них требует экранирования, иначе строка, содержащая разметку, становится XSS. Просмотрщику JSON не нужен форматированный текст, поэтому текстовые узлы плюс имена классов и безопаснее, и быстрее.

7. Подсветка синтаксиса: CodeMirror и Monaco — разные инструменты

Древовидное представление и текстовый редактор — разные модели отрисовки, поэтому «что быстрее» — не прямое сравнение:

Аспект CodeMirror 6 Monaco Editor
Позиционирование Модульное ядро редактора Полноценный IDE-редактор
Отрисовка На основе области просмотра На основе области просмотра, но модели и декорации тяжелее
Парсинг Инкрементальный парсинг Lezer по изменённым диапазонам Токенизатор Monarch, построчно
Большие файлы Зависит от расширений; лёгкий по умолчанию Полный по функциям, выше память и стоимость запуска
Сворачивание и поиск Отдельные расширения, загружаются по требованию Встроенные, с постоянными накладными расходами
Типичная ловушка Слишком много расширений возвращает стоимость Миникарта, липкая прокрутка и накопленные декорации

Два практических вывода:

  • Древовидному представлению не нужен полный токенизатор. Дайте ключам, строкам, числам и булевым по одному цвету через обычные текстовые узлы и CSS-классы; это стоит на порядок меньше, чем запуск подсветки синтаксиса;
  • Не монтируйте дерево и редактор одновременно. Поддержание двух моделей для одного документа удваивает память; уничтожайте старое представление вместо его скрытия.

Если редактор действительно принадлежит просмотрщику, CodeMirror 6 подходит для «только чтение, большой файл, локальная подсветка», а Monaco — для автодополнения, диагностики и мультикурсорного редактирования. Измерьте время первой отрисовки и резидентную память до чтения списка функций. Более глубокое сравнение редакторов и инструментов CLI см. в Редакторы, инструменты командной строки и автоматизация.

8. Границы Worker: уберите парсинг и построение строк из главного потока

Другая половина подвисания отрисовки — парсинг в главном потоке. Для JSON больше 1 МБ парсинг, форматирование и построение плоских строк принадлежат Web Worker; главный поток только принимает строки и отрисовывает область просмотра.

Worker не бесплатны: postMessage использует структурированное клонирование, поэтому отправка графа объектов в 50 МБ обратно в главный поток строит вторую копию и удваивает пик. Правильная граница — парсить и уплощать внутри Worker, возвращать только строки, нужные отрисовщику:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

Если данные строк всё ещё велики, сожмите строки в параллельные массивы (путь, тип, смещение значения), поместите их в ArrayBuffer и передайте владение, чтобы главный поток не платил за копию. Цена — написание собственного сериализатора, что стоит только на миллионах строк.

Правило границы простое: главный поток должен делать только работу, которая должна касаться DOM. Парсинг, поиск, сортировка, форматирование и сравнение могут уйти. Отрисовка, прокрутка, ввод и фокус остаются в главном потоке, и каждый кадр должен укладываться в бюджет работы.

9. Метрики и порядок реализации

Оптимизация не может останавливаться на «кажется быстрее». Следующий набор может сразу войти в контрольный список приёмки:

Метрика Цель Как измерять
Первый видимый кадр Меньше 300 мс performance.now() от открытия до первой отрисовки
Длинные задачи Ни одной больше 50 мс PerformanceObserver, следящий за longtask
Время кадра прокрутки Стабильно меньше 16.7 мс Запись Performance в DevTools
Элементы DOM в области просмотра Меньше 2,000 document.querySelectorAll('*').length
Отклик раскрытия / свёртывания Меньше 50 мс Метки времени вокруг взаимодействия
Поиск по 100,000 строк Меньше 300 мс, внутри Worker Время в Worker и возврат длительности
Резидентная куча Меньше 3x размера текста Снимок кучи Memory в DevTools

Измеряйте на реальном файле, а не на однородно сгенерированных маленьких объектах; форма данных важна, и длинные строки, глубокая вложенность и очень широкие массивы дают разные кривые. О самом реестре графа объектов см. Реестр памяти JSON.

Реализуйте в этом порядке:

  1. Измерьте базовый уровень: время первой отрисовки, число узлов, время кадра прокрутки;
  2. Свёрнутость по умолчанию: раскрывайте два уровня, показывайте тип и количество в сводках;
  3. Виртуальный список: фиксированная высота строки, стабильные ключи, область просмотра плюс небольшой буфер;
  4. Перенесите вычисления: парсинг, поиск и уплощение в Worker;
  5. Ограничьте подсветку: стилизуйте только видимые строки, избегайте перерисовки всего дерева;
  6. Добавьте ограждения: выше порога предлагайте «показать первые N строк» или требуйте фильтр поиска.

Каждый шаг разделяет одну цель: уменьшить узлы и объекты, которые должны существовать в один момент, вместо того чтобы просить браузер работать над ними усерднее. Полный фон многослойной стратегии — в Парсинг, отрисовка и потоковая обработка большого JSON.

Может ли виртуальная прокрутка исправить подвисание при парсинге?

Нет. Виртуальная прокрутка только уменьшает узлы DOM. Если парсинг всё ещё происходит в главном потоке, страница всё равно подвисает. Перенесите парсинг в Worker для документов больше 1 МБ и трактуйте две проблемы отдельно.

Всё раскрыто, и всё равно подвисает. Что не так?

Либо виртуальный список фактически не активен, либо каждое раскрытие пересканирует всё дерево. Проверьте, создают ли закадровые строки всё ещё DOM, и пересчитывает ли раскрытие только затронутое поддерево.

CodeMirror или Monaco для просмотрщика JSON?

Предпочитайте CodeMirror 6 для только-чтения, больших файлов и локальной подсветки; его модульная загрузка по требованию легче. Выбирайте Monaco, когда нужны автодополнение, диагностика и мультикурсорное редактирование. С любым из них избегайте монтирования дерева и редактора для одного документа одновременно.

Почему позиция прокрутки неверна после прыжка поиска?

Обычно потому, что вид прокрутился до раскрытия. Раскройте всех предков первыми, затем установите позицию прокрутки по новому индексу строки и подсветите последним. Ключайте строки по пути, а не по индексу, чтобы раскрытие не сдвигало каждый индекс.

Вопросы и ответы

Как узнать, с какого этапа приходит подвисание?

Разделите «медленно» на парсинг и построение объектов, создание узлов DOM, раскладку и отрисовку и отклик на взаимодействие и измерьте каждый этап через performance.now() от открытия до первой отрисовки. Затем следите за longtask через PerformanceObserver — любая задача больше 50 мс теряет кадры, а запись Performance в DevTools показывает, остаётся ли время кадра прокрутки меньше 16.7 мс.

Почему отрисовка всего дерева сразу делает экран пустым?

Документ в 5 МБ — примерно 125,000 строк, а древовидное представление обычно превращает каждую строку в 3–6 узлов DOM — примерно 500,000 элементов сразу. Каждый элемент также несёт правила стилей, бокс раскладки и инструкции отрисовки, поэтому несколько задач главного потока больше 50 мс подряд делают страницу мёртвой. Узлы, скрытые свёрнутым родителем, продолжают стоить работы раскладки, поэтому «отрисовать всё, затем скрыть» не является оптимизацией.

Стоит ли отправлять граф объектов в 50 МБ обратно из Worker?

Нет. postMessage использует структурированное клонирование, поэтому принимающая сторона строит вторую копию, и пик удваивается. Правильная граница — парсить и уплощать внутри Worker и возвращать только строки, нужные отрисовщику; на миллионах строк можно упаковать параллельные массивы (путь, тип, смещение значения) в ArrayBuffer и передать владение.