Представьте компонент, отображающий 10 000 строк в таблице. При первом рендере React создаёт DOM-элементы для всех элементов сразу — это 10 000 div'ов, каждый с обработчиками событий и стилями. На практике такой компонент зависает на 2-3 секунды даже на современных устройствах, потребляет лишние 100+ МБ памяти, а прокрутка работает рывками. Почему это происходит и как это исправить?
Остекленевший DOM: Когда размер имеет значение
Каждый DOM-элемент — не просто HTML-тег. Браузер создаёт сложную внутреннюю структуру (RenderObject, ComputedStyle), отслеживает геометрию через Layout Tree, перерисовывает слои. При 10k элементов:
- Время инициализации растёт линейно (O(n))
- Layout calculations занимают 80% времени при изменении размеров
- Объём GPU-памяти для композитинга резко увеличивается
Но пользователь видит одновременно не 10k элементов, а 10-20. Прокручивая список, человек физически не может обработать всю информацию сразу — это ключ к оптимизации.
Виртуализация: Рендеринг что видимо
Принцип windowing: рендерить только видимую часть данных, подменяя элементы при прокрутке. Реализация требует трёх компонентов:
- Контейнер с фиксированной высотой
- Механизм отслеживания позиции прокрутки
- Алгоритм вычислиения видимого диапазона
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const App = () => (
<List
height={600}
itemCount={10000}
itemSize={35}
width={300}
>
{Row}
</List>
);
Этот код из библиотеки react-window
рендерит 17 элементов вместо 10k — сокращение DOM-узлов на 99.8%. Перерисовка при скролле работает через трансформации CSS, избегая повторного layout.
Под капотом виртуализации
Контрольные точки реализации:
-
Измерение позиций
Элементы должны иметь фиксированную высоту или уметь вычислять её динамически (дляVariableSizeList
). Библиотека кэширует измерения в Map<index, height>. -
Ленивое обновление
Скролл листа вызывает частые событияonScroll
. Дросселирование (не более 1 вызова в 16мс) предотвращает перегрузку Event Loop. -
Оверрендеринг
Рендер дополнительных 2-3 элементов сверху/снизу предотвращает появление пустот при быстрой прокрутке. -
Кеширование состояний
При повторном отображении элемента (например, возврат к предыдущей позиции скролла) компоненты должны сохранять внутреннее состояние. Решение — ключизация по индексу.
Когда виртуализация не спасает: Динамический контент
Список с элементами переменной высоты (разворачиваемые панели, изображения с lazy loading) требует параллельного подхода:
const sizeMap = new Map();
const setSize = (index, size) => sizeMap.set(index, size);
const getSize = index => sizeMap.get(index) || 50;
const DynamicList = () => (
<VariableSizeList
itemSize={getSize}
/* ... */
>
{({ index, style }) => (
<DynamicItem
index={index}
style={style}
onSizeChange={setSize}
/>
)}
</VariableSizeList>
);
Здесь родительский список пересчитывает позиции элементов при получении новых данных от дочерних компонентов. Для предотвращения layout thrashing обновления размера группируются через requestAnimationFrame.
Альтернативы и компромиссы
-
Пагинация
Проста в реализации, но разрушает пользовательский опыт при навигации "вперёд-назад". -
Infinite Scroll
Комбинируется с виртуализацией для постепенной подгрузки данных. Требует аккуратной работы с метриками загрузки и обработкой ошибок. -
CSS-хитрости
content-visibility: auto
в современных браузерах делегирует рендеринг графическому движку. Подходит для статических списков, слабо контролируется из JavaScript.
Нагрузочные тесты на MacBook M1 Pro (Chrome 115):
Метод | Время рендеринга | Потребление памяти | FPS при скролле |
---|---|---|---|
Нативный рендер | 2,800ms | 450MB | 12 |
react-window | 18ms | 62MB | 60 |
CSS-виртуализация | 24ms | 210MB | 58 |
Неочевидные ловушки
-
Фокус и состояние формы
Виртуальные списки пересоздают элементы при скролле. Поля ввода внутри должны управляться через поднятие состояния или синхронизацию с внешним хранилищем. -
Ссылки на DOM-элементы
ref={element => (this.items[index] = element}
приведёт к утечкам памяти. Используйте callback refs с очисткой при удалении элемента из DOM. -
Горизонтальный скролл + колонки
Работает черезlayout="horizontal"
, но требует точного расчёта ширины колонок и координат скролла. ПроверятьscrollLeft
вместоscrollTop
.
Для списков сложнее 10k элементов с динамическим содержимым стоит рассмотреть специализированные решения вроде AG Grid или платные библиотеки вродемонифицированных рендер-движков. Однако для 95% кейсов правильно настроенная виртуализация уменьшит время первого рендера в 100-200 раз, сохраняя интерактивность на уровне 60 FPS. Главное — начать замерять: React DevTools Profiler и Chrome Performance Panel покажут, куда уходят ресурсы.