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. Пошук і підсвічування: обчислити один раз, показати трохи

Пошук — найчастіше проґавлена пастка продуктивності в переглядачі дерев. Якщо кожне натискання клавіші обходить весь граф обʼєктів і перебудовує список результатів, документ на сто тисяч вузлів замерзне миттєво.

Багаторівневий підхід:

  • Зробіть debounce введення на 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; головний потік лише бере рядки й малює вікно перегляду.

Workers не безкоштовні: postMessage використовує структуроване клонування, тож надсилання графа обʼєктів на 50 МБ назад у головний потік будує другу копію й подвоює пік. Правильна межа — розбирати й сплющувати всередині Worker, повертати лише рядки, потрібні рендереру:

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

worker.postMessage({ text });

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

Правило для межі просте: головний потік має робити лише роботу, що мусить торкатися DOM. Розбір, пошук, сортування, форматування й diff можуть усі піти. Відображення, прокрутка, введення й фокус залишаються на головному потоці, і кожен кадр має вміщуватися в бюджет роботи.

9. Метрики й порядок впровадження

Оптимізація не може зупинитися на «відчувається швидше». Наступний набір можна вставити прямо в чек-лист приймання:

Метрика Ціль Як міряти
Перший видимий кадр Менше 300 мс performance.now() від відкриття до першого малювання
Довгі задачі Жодної понад 50 мс PerformanceObserver, що стежить за longtask
Час кадру прокрутки Стабільно менше 16,7 мс Запис Performance в інструментах розробника
DOM-елементи у вікні перегляду Менше 2 000 document.querySelectorAll('*').length
Відповідь розгортання / згортання Менше 50 мс Мітки часу довкола взаємодії
Пошук понад 100 000 рядків Менше 300 мс, усередині Worker Час у Worker і повернення тривалості
Резидентна купа Менше 3x розміру тексту Знімок купи в панелі Memory інструментів розробника

Міряйте на реальному файлі, а не на рівномірно згенерованих маленьких обʼєктах; форма даних важлива, а довгі рядки, глибока вкладеність і дуже широкі масиви дають різні криві. Про саму книгу обліку графа обʼєктів дивіться Книга обліку памʼяті 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 в інструментах розробника показує, чи час кадру прокрутки залишається під 16,7 мс.

Чому відображення всього дерева одразу робить екран порожнім?

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

Чи надсилати граф обʼєктів на 50 МБ назад із Worker?

Ні. postMessage використовує структуроване клонування, тож сторона-одержувач будує другу копію й пік подвоюється. Правильна межа — розбирати й сплющувати всередині Worker і повертати лише рядки, потрібні рендереру; на мільйонах рядків ви можете запакувати паралельні масиви (шлях, тип, зміщення значення) в ArrayBuffer і передати володіння.