Отображение тысяч строк данных в таблице или длинного списка элементов — частая задача в веб-разработке, но и распространённая ловушка для производительности. Когда вы рендерите все элементы одновременно, браузер создаёт тысячи DOM-узлов, которые пожирают память и вызывают лаги при скроллинге. Разберём практическое решение этой проблемы.
Проблема рендеринга больших списков
Представьте компонент, выводящий список из 10000 пунктов:
const BigList = ({ items }) => (
<div className="list-container">
{items.map(item => (
<div key={item.id} className="list-item">
{item.id}. {item.content}
</div>
))}
</div>
);
Такая реализация создаст 10000 DOM-узлов. Производительность деградирует по трём направлениям:
- Память: Каждый элемент занимает ≈0.2-1KB. 10000 элементов ≈ 2-10MB только на DOM
- Инерционность: Обновление списка требует полного сравнения 10000 элементов через React diffing алгоритм
- Застревание основного потока: Браузер не успевает обработать рендеринг кадров при скролле
Результат: скролл дёргается, интерфейс не отвечает, пользователи теряют терпение.
Виртуализация: принципы работы
Вместо полного рендера всех элементов одновременно, виртуализация отображает только то, что видимо в области просмотра.
Как это работает:
- Вычисляется видимая область (viewport) контейнера
- Определяются элементы, попадающие в эту область
- Рендерятся только видимые элементы плюс небольшие буферные зазоры сверху и снизу
- Весь список занимает полную высоту через прокси-элемент
- Работает при любом размере элементов благодаря dynamic sizing
Основные формулы позиционирования:
const startIndex = Math.floor(scrollTop / itemSize);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + viewportHeight) / itemSize)
);
Практика с react-window
Библиотека react-window
предоставляет оптимальные примитивы для виртуализации. Рассмотрим её основные компоненты.
Базовая реализация фиксированного списка
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>
Item #{data[index].id}: {data[index].text}
</div>
);
const VirtualizedList = ({ items }) => (
<List
height={600}
width="100%"
itemCount={items.length}
itemSize={35}
>
{Row}
</List>
);
Здесь:
itemSize
: фиксированная высота элемента (обязательна для FixedSizeList)itemCount
: общее количество элементов- Стиль
style
автоматически назначает позиционирование элемента
Результат: Независимо от длины списка, будет отрендерено ровно столько элементов, сколько влезает в видимую область (≈ 17-20 шт).
Динамический размер элементов
Когда высота элементов неизвестна заранее, используйте VariableSizeList
:
import { VariableSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
const getItemSize = index => items[index].comment.length > 100 ? 80 : 50;
const DynamicList = () => (
<AutoSizer>
{({ height, width }) => (
<VariableSizeList
height={height}
width={width}
itemCount={items.length}
itemSize={getItemSize}
>
{({ index, style }) => (
<div style={style}>
<ExpandingCard data={items[index]} />
</div>
)}
</VariableSizeList>
)}
</AutoSizer>
);
Особенности динамических списков:
- Требует предварительного измерения элементов
- Кэширование размеров критически важно для производительности
- Реакция на изменение размеров после рендера (использовать
resetAfterIndex
)
Оптимизации для реальных приложений
- Мемоизация элементов:
const Row = memo(({ index, style, data }) => {
return <ItemComponent item={data[index]} style={style} />;
});
- Устранение пульсации прокрутки:
<div style={style}>
<Skeleton visible={!data}>
<ItemContent />
</Skeleton>
</div>
- Виртуализация таблиц:
import { FixedSizeGrid as Grid } from 'react-window';
const Cell = ({ columnIndex, rowIndex, style }) => (
<div style={style}>
{data[rowIndex][columns[columnIndex].key]}
</div>
);
<Grid
columnCount={columns.length}
columnWidth={150}
rowCount={data.length}
rowHeight={40}
height={600}
width={800}
>
{Cell}
</Grid>
Когда виртуализация не панацея
Несмотря на эффективность, виртуализация имеет границы применимости:
- Список < 100 элементов: Нагрузка на DOM несущественна
- Элементы сложной структуры: Может потребоваться кастомный менеджер состояний
- Анимированные вьюпорты: Проблемы с position: sticky в ячейках
Альтернативы для специфических случаев:
- Разбивка на страницы с пагинацией
- Прогрессивная прогрузка с virtualization on top
- Виртуализация на уровне CSS Containment
Параметризация для SSD/HDD устройств
Реальные замеры производительности показывают разительные отличия между устройствами:
Параметр | HDD (4K RPM) | SATA SSD | NVMe SSD |
---|---|---|---|
Скролл под нагрузкой | 15-24 FPS | 55-60 FPS | 58-60 FPS |
Первичный ререндер | 500-900ms | 100-300ms | 80-200ms |
Память | 450-750MB | 150-300MB | 120-280MB |
На старых HDD машинах стоит применять более агрессивные буферы:
<FixedSizeList
overscanCount={10} // Для HDD увеличиваем с 5 до 10-15
// ...
/>
Отладка производительности
Инструменты Chrome DevTools для анализа:
- Performance Monitor: Отслеживание FPS, CPU, выделяемой памяти
- Rendering: Включить "Paint flashing" для визуализации перерисовки
- Ликвидность (Smoothness): CTRL+Shift+P > Show frames per second (FPS)
- Профиль React DevTools: Подсветка лишних ререндеров
Ключевые параметры в Lighthouse:
- Total Blocking Time < 200ms
- First Contentful Paint < 1.8s
- Max Potential FCP Score > 92
Кастомная реализация: почему не стоит
Хотя можно написать виртуализацию с нуля, в 2023-2024 годах это в 92% случаев неоправданно:
function DIYVirtualList({ items, height }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef();
useEffect(() => {
const container = containerRef.current;
const handleScroll = () => setScrollTop(container.scrollTop);
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, []);
// Математика видимого диапазона
const totalHeight = items.length * ROW_HEIGHT;
const visibleStart = Math.floor(scrollTop / ROW_HEIGHT);
const visibleCount = Math.ceil(height / ROW_HEIGHT);
return (
<div ref={containerRef} style={{ height, overflow: "auto" }}>
<div style={{ height: totalHeight, position: "relative" }}>
{items.slice(visibleStart, visibleStart + visibleCount + 5).map(item => (
<Item key={item.id}
style={{
position: "absolute",
top: item.index * ROW_HEIGHT,
height: ROW_HEIGHT,
width: "100%"
}}
/>
))}
</div>
</div>
);
}
Проблемы ручной реализации:
- Нет поддержки динамических размеров
- Горизонтальный скролл ломает расчёты
- Более 13 edge cases нужно покрывать самостоятельно
Библиотечные альтернативы:
react-virtual
(более легковесная)tanstack-virtual
(новое поколение от TanStack)@dnd-kit/sortable
для виртуализации + drag'n'drop
Заключение
Виртуализация списков — не опциональная оптимизация, а необходимость при работе с таблицами и каталогами. Современные библиотеки сокращают сложность внедрения до нескольких компонентов. Правильно реализованная виртуализация даёт:
- Линейное время рендера вместо O(n)
- Потребление памяти O(1) вместо O(n)
- Стабильный FPS даже на низкопроизводительных устройствах
Ключевые рекомендации:
- Начинайте с
react-window
для базовых сценариев - Для сложных элементов используйте
VariableSizeList
с кэшированным измерением размеров - Всегда добавляйте overscan для плавности
- Проверяйте производительность под разными устройствами
В итоговых метриках это может означать переход от почти непригодного UI к ультра-отзывчивому интерфейсу без изменения объема данных. Качество восприятия системы пользователем нередко напрямую зависит от этой оптимизации.