Чому великі дерева JSON гальмують: віртуальна прокрутка й стратегії згортання
Головне: Гальмування починається зі створення DOM-вузлів, а не з прокрутки. Охоплює відображення зі згортанням спершу, віртуальні списки, підсвічування пошуку й межі Worker, із метриками.

Коротко: розділіть «повільно» на розбір, створення вузлів, компонування й малювання та взаємодію, і виміряйте кожен етап. Впроваджуйте в такому порядку — згортання за замовчуванням, додайте віртуальний список, перенесіть обчислення в 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. Згортання, розгортання й привʼязка прокрутки
Щойно індекс рядка стає єдиною координатою, кілька деталей взаємодії починають ламатися:
- Зберігайте візуальний якір під час згортання над вікном перегляду. Рядки нижче зсуваються вгору; якщо
scrollTopне скориговано в тому самому коміті, вміст наче стрибає. Запишіть шлях якірного рядка й відновіть позицію прокрутки за його новим індексом. - Розгорніть предків перед переходом. Коли пошук влучає в глибоке поле, спершу розгорніть кожного батька, потім обчисліть індекс цільового рядка, потім прокрутіть і підсвітіть. Пропустіть перший крок — і цільового рядка навіть не буде у видимому масиві.
- Керуйте клавіатурною навігацією з видимих рядків. Вгору й вниз рухають індекс рядка; ліворуч і праворуч розгортають і згортають. Ніколи не обходьте все дерево в обробнику клавіш.
- Пакетуйте оновлення в один кадр. Розгортаючи кілька рівнів підряд, злийте зміни масиву й відобразіть один раз, інакше кожен рівень спричиняє повне перекомпонування.
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.
Впроваджуйте в такому порядку:
- Виміряйте базовий рівень: час першого відображення, кількість вузлів, час кадру прокрутки;
- Згортання за замовчуванням: розгорніть два рівні, покажіть тип і кількість у підсумках;
- Віртуальний список: фіксована висота рядка, стабільні ключі, вікно перегляду плюс невеликий буфер;
- Перенесіть обчислення: розбір, пошук і сплющення в Worker;
- Обмежте підсвічування: стилізуйте лише видимі рядки, уникайте перемалювання всього дерева;
- Додайте запобіжники: вище порогу запропонуйте «показати перші 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 і передати володіння.