Що таке віртуалізація і навіщо вона потрібна
Віртуалізація рендерить лише ті DOM-вузли, які видно у viewport, і переставляє фіксований пул із ~20-50 елементів під час прокрутки, незалежно від реальної кількості записів у списку.
Теорія
TL;DR
- Аналогія: театр із 20 місцями, де актори виходять через бічні двері зі зміщенням прожектора. 10 000 місць будувати не потрібно.
- Повний рендер: 10 000 DOM-вузлів для 10 000 елементів. Зі віртуалізацією: 20-50 вузлів, які переставляються.
- Пам'ять падає з 100+ МБ до ~2 МБ для того самого набору даних.
- Початкове завантаження скорочується з 2+ с до ~100 мс.
- Вмикай коли прокручуваний список більше 500 елементів і scroll гальмує; пропускай для коротких статичних списків.
Швидкий приклад
// Без віртуалізації: 10 000 DOM-вузлів, браузер гальмує при прокрутці
const SlowList = ({ items }) => (
<ul style={{ height: '400px', overflow: 'auto' }}>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
// З react-window: ~20 вузлів незалежно від items.length
import { FixedSizeList as List } from 'react-window';
const FastList = ({ items }) => (
<List height={400} itemCount={items.length} itemSize={35}>
{({ index, style }) => <div style={style}>{items[index].name}</div>}
</List>
);
// Відкрий DevTools при прокрутці: ті самі ~20 div переставляються, нові не додаютьсяБібліотека позиціонує кожен видимий рядок через transform: translateY(), тому браузер пропускає reflow і тримає 60fps протягом усієї прокрутки.
Як працює пул DOM-елементів
Браузер визначає, які рядки потрапляють у viewport через getBoundingClientRect() або ResizeObserver. Бібліотека тримає невеликий буфер (overscan) - зазвичай 3-5 рядків вище і нижче видимої зони. Коли користувач прокручує, наявні DOM-вузли отримують новий вміст і новий translateY-офсет. Жодних нових вузлів не створюється і не видаляється під час прокрутки.
React звіряє вузли за key кожного virtual item. Якщо пропустити key, React перерендерить усі рядки при кожному скролі. Ця одна помилка опускає frame rate із 60fps до 10fps і трапляється на code review частіше, ніж хотілося б.
Коли використовувати
- Список або таблиця більше 500 елементів -> віртуалізуй.
- Безкінечне прокручування (inbox, стрічка новин) -> завантажуй частинами і віртуалізуй видиму частину.
- Деревоподібні структури на кшталт файлового менеджера -> віртуалізуй тільки розгорнуті гілки.
- Мобільний застосунок зі списком більше 200 елементів -> завжди (пам'яті на телефоні менше).
- Статичний список менше 200 елементів -> пропускай. Бібліотека додає ~10 кБ без реальної вигоди.
Порівняльна таблиця
| Повний рендер | Віртуалізація | |
|---|---|---|
| DOM-вузли (10 000 елементів) | 10 000 | 20-50, переставляються |
| Прокрутка 60fps | Ні | Так |
| Пам'ять (10 000 елементів) | 100+ МБ | ~2 МБ |
| Початкове завантаження | 2+ с | ~100 мс |
| Підходить для | <500 статичних елементів | >500 динамічних або прокручуваних списків |
Типові помилки
Немає key на virtual items. React потребує стабільний ключ, щоб розуміти, який елемент відповідає якому DOM-вузлу. У розробці список виглядає нормально, але в продакшені падає до 10fps, коли дані часто оновлюються.
// Неправильно - повний перерендер при кожній прокрутці
{virtualItems.map(item => <div>{data[item.index]}</div>)}
// Правильно
{virtualItems.map(item => <div key={item.key}>{data[item.index]}</div>)}Нульовий overscan при швидкій прокрутці. Коли користувач різко свайпає, між рядками з'являються білі смуги.
// Неправильно - білі прогалини при швидкому флінгу
const virtualizer = useVirtualizer({ count: items.length, overscan: 0 });
// Правильно - 5 зайвих рядків вище і нижче viewport
const virtualizer = useVirtualizer({ count: items.length, overscan: 5 });Фіксована висота рядка для вмісту зі змінною висотою. Повідомлення з картинками ламають розмітку, якщо вважати кожен рядок за 50px.
// Неправильно - прогалини та зміщення при емодзі або зображеннях
const virtualizer = useVirtualizer({ estimateSize: () => 50 });
// Правильно - вимірюй реальні висоти під час рендеру
const virtualizer = useVirtualizer({
count: messages.length,
estimateSize: () => 50,
measureElement: el => el?.getBoundingClientRect()?.height || 50,
});Віртуалізація малих списків. Підключати react-window для 150 елементів - це 10 кБ JS без реальної вигоди. Просто .map().
Вкладена віртуалізація. Virtual list всередині virtual table дає подвійні translateY-трансформи, які переповнюються і ламають позиціонування. Вирівняй структуру або використай position: sticky для заголовків.
Де використовується
- Jira і Gmail web використовують для списків задач та inbox із 10 000+ рядків.
- TanStack Table v8+ підтримує virtual rows через
@tanstack/react-virtual. - Ant Design Table всередині використовує
vc-virtual-listдля enterprise-дашбордів. - Vercel admin panels віртуалізують вивід логів при безкінечному прокручуванні.
- Для нових проектів краще
@tanstack/react-virtual(активно підтримується, ~5 кБ) ніжreact-virtualized(legacy, важча).
Питання на співбесіді
Q: У чому різниця між virtualization і windowing?
A: Це одне й те саме. "Windowing" - старий термін із часів react-virtualized, зараз у спільноті кажуть "virtualization".
Q: Як react-window підтримує змінні висоти рядків?
A: Через VariableSizeList з функцією getItemSize(index). Бібліотека кешує виміряні висоти і перераховує scroll-офсети при зміні розміру контейнера.
Q: Як React 18 впливає на продуктивність віртуалізації?
A: startTransition огортає оновлення обробника прокрутки, роблячи їх низькопріоритетними. Заїкання зникає навіть коли рядки містять важкі компоненти, бо React може перервати оновлення заради пріоритетніших подій.
Q: Чому не просто CSS contain: layout замість видалення DOM-вузлів?
A: contain підказує браузеру пропустити перерахунок стилів за межами контейнера, але вузли залишаються в DOM. Для 50 000 рядків це не рятує від проблем із пам'яттю.
Q (senior-рівень): ResizeObserver ламає віртуалізацію в Safari 15-16. Як виправити без заміни бібліотеки?
A: Додай поліфіл через MutationObserver на scroll-контейнер, throttle scroll-обробника до 16 мс і додай window.resize як fallback. Цей баг - відоме питання на мобільних співбесідах у FAANG.
Приклади
Базовий: фіксована висота рядка з react-window
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Користувач #{index}</div>
);
function UserList({ users }) {
return (
<List height={400} itemCount={users.length} itemSize={35} width="100%">
{Row}
</List>
);
}
// У DOM ~20 рядків при будь-якій позиції прокруткиКожен рядок 35px заввишки. itemSize має збігатися з реальною CSS-висотою, інакше позиція прокрутки зміщується. Відкрий DevTools при скролі - побачиш одні й ті самі ~20 div, які переставляються, а не нові.
Продакшен: динамічний список через TanStack Virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
function IssueList({ issues }) { // наприклад, 50 000 задач із API
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: issues.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${item.start}px)`,
width: '100%',
}}
>
{issues[item.index].title}
</div>
))}
</div>
</div>
);
}
// ~30 рядків у DOM при прокрутці 50 000 задачЗовнішній div отримує повну віртуальну висоту, тому scrollbar точно відображає прогрес у списку. Кожен видимий рядок позиціонується через translateY - без перерахунку layout. Це той самий патерн, який використовують TanStack Table v8 і Shadcn-дашборди.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.