Проблема:
function UserList() {
const users = fetchAllUsers(); // 10,000+ записей
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Этот код может привести к катастрофе производительности. Каждый элемент UserCard
, даже невидимый пользователю, создаёт DOM-ноду, потребляет память и загружает поток рендеринга.
Принцип виртуализации:
Отображать только видимую часть контента + небольшой буфер сверху/снизу. При прокрутке - динамически заменять элементы. Результат:
- Фиксированное количество DOM-элементов независимо от размера данных
- Нагрузка на GPU вместо CPU
- Плавная прокрутка на мобильных устройствах
Реализуем базовую виртуализацию
Шаг 1: Определяем видимую область
const VirtualList = ({ items, itemHeight, buffer = 5 }) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
const handler = () => setScrollTop(containerRef.current.scrollTop);
containerRef.current.addEventListener('scroll', handler);
return () => containerRef.current.removeEventListener('scroll', handler);
}, []);
const viewportHeight = containerRef.current?.clientHeight || 0;
// ...
};
Шаг 2: Рассчитываем видимые индексы
const innerHeight = items.length * itemHeight;
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - buffer
);
const endIndex = Math.min(
items.length - 1,
startIndex + Math.ceil(viewportHeight / itemHeight) + buffer * 2
);
const visibleItems = items.slice(startIndex, endIndex + 1);
Шаг 3: Перемещаем контент с трансформацией
<div
ref={containerRef}
style={{ height: '100vh', overflowY: 'auto' }}
>
<div style={{ height: `${innerHeight}px`, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
position: 'absolute',
top: `${(startIndex + index) * itemHeight}px`,
width: '100%'
}}
>
<UserCard user={item} />
</div>
))}
</div>
</div>
Замеры производительности до/после
Параметр | Без виртуализации (10K элементов) | С виртуализацией |
---|---|---|
DOM nodes | 10200+ | 50 |
Время рендеринга | 3200 мс | 8 мс |
Потребление памяти | 380 МБ | 85 МБ |
FPS при скролле | 3-4 | 58-60 |
Ключевые оптимизации
Фиксированная vs. динамическая высота
// Для элементов переменной высоты:
const measuredRef = useRef();
<Item
ref={measuredRef}
onMeasure={(height) => updateItemHeight(index, height)}
/>
- Используйте ResizeObserver
- Кешируйте измерения
- Асинхронный рендеринг после измерений
Чёткие и размытые трансформации
.will-change-transform {
will-change: transform;
backface-visibility: hidden;
}
Без этой оптимизации анимация прокрутки может прерываться при смене элементов.
Асинхронный рендеринг при быстрой прокрутке
let scrollTimeout;
const handleScroll = () => {
clearTimeout(scrollTimeout);
// Показать индикатор загрузки при быстром скролле
if (isFastScroll) showSkeleton();
scrollTimeout = setTimeout(() => {
updateVisibleItems();
hideSkeleton();
}, 50);
};
Когда и почему виртуализация вредна
- Слишком маленькие списки (<100 элементов) - оверхед превышает выгоду
- Таблицы с фоновыми вычислениями - виртуализация может нарушить структуру таблиц
- Контент со вложенными прокрутками - конфликтует с родительским scroll container
Альтернативы: CSS vs JS
CSS Containment
.container {
content-visibility: auto;
contain-intrinsic-size: 400px; /* Оценочная высота */
}
Плюсы:
- Нативные браузерные оптимизации
- Нет логики на JavaScript
Минусы:
- Не поддерживает пользовательские буферы
- Ограниченный контроль над поведением
- Отображение неизмеренного содержимого "блэкауты"
Реальные кейсы из производства
Кейс 1: Фильтрация и виртуализация
При фильтрации 50K записей:
- Исходное решение: 3000мс до отклика
- Оптимизация:
javascript
useMemo(() => { // Предвычисление видимых индексов return largeDataSet.filter(fn).slice(0, 100); }, [deps]);
Результат: обновление за 110мс
Кейс 2: Анимации в виртуальном списке
Проблема: анимации схлопывания нарушались при уходе элементов из области видимости. Решение:
<AnimatePresence>
{visibleItems.map(item => (
<motion.div
key={item.id}
exit={{ height: 0 }}
style={{ position: 'absolute', top: position }}
>
<Content />
</motion.div>
))}
</AnimatePresence>
Когда использовать библиотеки
Библиотеки типа React-Window и React-Virtualized решают:
- Поддержка горизонтальной виртуализации
- Динамические размеры элементов
- Прерываемый рендеринг
- Разделение измерений и рендеринга
Экономия памяти в Action
import { FixedSizeList } from 'react-window';
<List
height={600}
itemCount={100000}
itemSize={75}
width={300}
>
{({ index, style }) => (
<div style={style}>
Пользователь #{index}
</div>
)}
</List>
Глубокое погружение: Windowed vs. контекстное решение
Windowed (traversal virtualization)
- Физически перемещаем DOM-ноды
- Проблемы: сброс состояния компонентов
Контекстное (render virtualization)
{isLoading ? (
<Skeleton />
) : (
<RealContent />
)}
- Идеально для сохранения состояния
- Требует стабильных ключей
Перспективы: новое в браузерах
Скролл-анимации с View-Transitions API
::view-transition-old(list-item),
::view-transition-new(list-item) {
height: auto;
mix-blend-mode: normal;
}
Container Queries + Virtualization
Автоматическое изменение структуры при уменьшении контейнера:
.card-container {
container-type: inline-size;
}
@container (max-width: 350px) {
.card {
flex-direction: column;
}
}
Заключение: чек-лист внедрения
-
Замерьте производительность до оптимизации
React DevTools Profiler
, Chrome Performance tab -
Выберите стратегию: │ ├── <100 элементов → CSS
content-visibility
├── Таблицы → Библиотеки для таблиц
└── Кастомный UI → Реализация или react-window -
Оркеструйте состояние:
javascript// Сохраняйте состояние вне отображаемых элементов const [, setScrollTop] = useState();
-
Тестируйте пограничные случаи:
- Экстренно быстрая прокрутка
- Резкое изменение размеров окна
- Тяжёлые пользовательские компоненты
- Динамические подгрузки данных
-
Анализ после внедрения:
- Lighthouse score для FCP, TBT
- Heap snapshots
- Input delay при скролле
Итог: Виртуализация — это не просто выбор между "импорт_библиотеки" и "написать_самому". Это архитектурное решение, требующее понимания механизмов браузерного рендеринга. Современные инструменты позволяют комбинировать нативные возможности CSS и JavaScript для создания плавных интерфейсов с тысячами элементов.