В современных веб-приложениях производительность отображения больших массивов данных ограничена не вычислительной мощностью JavaScript-движка V8, а физическими затратами рендер-движков браузера (Blink в Chrome, Gecko в Firefox, WebKit в Safari) на создание и обслуживание нативных узлов DOM.
Попытка единовременно отрисовать 10 000 или 100 000 элементов <div /> приводит к мгновенному параличу интерфейса по трем фундаментальным причинам:
ComputedStyle, слоев композитинга и слушателей событий. 10 000 сложных строк легко потребляют более 300 МБ памяти вкладки.Решением является паттерн Virtual Windowing (Виртуализация окон).
Вместо монтирования всего массива библиотека виртуализации создает иллюзию бесконечного списка:
overflow-y: auto.totalSize), высота которого равна произведению среднего размера элемента на общее число записей.transform: translateY(...), минуя затратные стадии перерасчета геометрии Layout.┌─────────────────────────────────────────────────────────┐
│ Внешний Скролл-Контейнер (overflow-y: auto, fixed height)│
│ ┌───────────────────────────────────────────────────┐ │
│ │ Фантомный контейнер (totalSize = 100 000 * 50px) │ │
│ │ │ │
│ │ [ Невидимые верхние элементы: translateY offset ]│ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ [Overscan: 3-5 элементов сверху] │ │ │
│ │ │ --------------------------------------------- │ │ │
│ │ │ │ │ │
│ │ │ ВИДИМОЕ ОКНО (VIEWPORT) │ │ │
│ │ │ Рендерятся только 12-18 реальных узлов │ │ │
│ │ │ │ │ │
│ │ │ --------------------------------------------- │ │ │
│ │ │ [Overscan: 3-5 элементов снизу] │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ [ Невидимые нижние элементы: пустое пространство]│ │
│ │ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Задайте контейнеру фиксированную высоту (например h-[600px] или h-screen), overflow-y: auto, свойство contain: strict и ref.
Сконфигурируйте count (длина массива), getScrollElement, estimateSize (оценка высоты) и overscan (5–8 для десктопа).
Создайте внутренний div с абсолютной высотой getTotalSize()px и относительным позиционированием для нативной полосы прокрутки.
Отрендерите элементы из getVirtualItems(), привяжите ref={measureElement} и позиционируйте через translateY.
Проверьте в панели Chrome Performance отсутствие просадок кадров и отсутствие Layout Shifts при скоростном скролле.
В чем заключается фундаментальное отличие виртуализации окон (Windowing) от стандартного Infinite Scroll (подгрузки страниц по скроллу)?
Контейнер генерирует событие scroll. useVirtualizer считывает актуальный scrollTop.
Бинарный поиск по кэшу высот для вычисления диапазона startIndex / endIndex с учетом overscan.
React монтирует только отфильтрованные элементы, позиционируя их через аппаратный transform.
measureElement фиксирует отклонения реального размера от estimateSize и обновляет кэш смещений.
В реальных интерфейсах карточки имеют разное количество текста, спойлеры или картинки. Константная высота estimateSize: () => 60 приводит к наложению строк друг на друга.
TanStack Virtual решает эту проблему интеграцией с нативным ResizeObserver:
estimateSize задает лишь начальное приближение до отрисовки строки.ref={rowVirtualizer.measureElement} с атрибутом data-index={virtualRow.index}.ResizeObserver считывает точные размеры после применения CSS и передает фактическую высоту в кэш виртуализатора.start для всех последующих элементов, полностью исключая визуальные наложения.<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} // Замер динамического размера через ref callback
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<DynamicMessageCard message={messages[virtualRow.index]} />
</div>В чатах новые сообщения добавляются внизу, а старая история подгружается вверх. Если сверху вставить 20 сообщений суммарной высотой 800px, браузер сохранит прежний scrollTop, из-за чего экран скачкообразно дернется вниз на 800px.
Для решения проблемы скачка используется алгоритм программной компенсации высоты:
// Алгоритм компенсации скролла при подгрузке истории сверху
const previousScrollHeight = containerRef.current.scrollHeight;
await loadOlderMessages();
// В следующем анимационном кадре компенсируем сдвиг
requestAnimationFrame(() => {
if (containerRef.current) {
const deltaHeight = containerRef.current.scrollHeight - previousScrollHeight;
containerRef.current.scrollTop += deltaHeight;
}
});Почему при виртуализации критически важно смещать строки с помощью CSS-свойства transform: translateY(...) вместо абсолютного позиционирования через top?
Ниже представлен полноценный компонент промышленного уровня, объединяющий useVirtualizer, динамический замер строк произвольной высоты и триггер бесконечной подгрузки страниц:
import React, { useRef, useEffect, useCallback } from 'react';
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
export interface FeedArticle {
id: string;
title: string;
body: string;
author: string;
createdAt: string;
}
interface VirtualInfiniteFeedProps {
items: FeedArticle[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
overscan?: number;
}
export const VirtualInfiniteFeed: React.FC<VirtualInfiniteFeedProps> = ({
items,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
overscan = 5,
}) => {
const parentRef = useRef<HTMLDivElement | null>(null);
// Общее количество строк = количество статей + 1 строка лоадера (если есть следующая страница)
const totalCount = hasNextPage ? items.length + 1 : items.length;
const rowVirtualizer = useVirtualizer({
count: totalCount,
getScrollElement: () => parentRef.current,
// Начальная эвристическая оценка высоты строки до монтирования в DOM
estimateSize: useCallback(() => 100, []),
overscan,
});
const virtualRows = rowVirtualizer.getVirtualItems();
// Триггер упреждающей подгрузки следующей страницы при приближении к концу скролла
useEffect(() => {
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= items.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage();
}
}, [hasNextPage, fetchNextPage, isFetchingNextPage, virtualRows, items.length]);
return (
<div
ref={parentRef}
className="w-full h-[650px] overflow-y-auto border border-border rounded-xl bg-bg-surface relative custom-scrollbar"
style={{ contain: 'strict' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualRows.map((virtualRow: VirtualItem) => {
const isLoaderRow = virtualRow.index > items.length - 1;
const item = items[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full p-4 border-b border-border/50 transition-colors hover:bg-bg-elevated/40"
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center py-4 text-accent-cyan font-mono text-xs animate-pulse">
Загрузка дополнительных данных...
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-accent-violet font-mono">
#{item.id} — {item.author}
</span>
<span className="text-[11px] text-text-muted">{item.createdAt}</span>
</div>
<h4 className="text-sm font-semibold text-text-primary leading-snug">
{item.title}
</h4>
<p className="text-xs text-text-muted leading-relaxed whitespace-pre-wrap">
{item.body}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
};Использование key={virtualRow.index} приводит к тому, что при скролле React переиспользует существующие DOM-узлы с чужим внутренним состоянием (например, открытые инпуты ввода или локальные состояния аккордеонов).
// ❌ Антипаттерн: при скролле компонент строки переиспользуется, состояние «залипает»
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.index} style={{ transform: `translateY(${virtualRow.start}px)` }}>
<RowItem item={items[virtualRow.index]} />
</div>
))}
// ✅ Правильное решение: использовать виртуальный ключ virtualRow.key
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{ transform: `translateY(${virtualRow.start}px)` }}>
<RowItem item={items[virtualRow.index]} />
</div>
))}Без изоляции рендеринга браузер вынужден пересчитывать стили и геометрию всего внешнего документа при изменении высоты фантомного контейнера (totalSize).
// ❌ Антипаттерн: скролл виртуального контейнера провоцирует рефлоу всей страницы
<div ref={parentRef} className="overflow-auto h-[500px]">
{/* Контент */}
</div>
// ✅ Правильное решение: изолировать layout, style и paint внутри скролл-бокса
<div
ref={parentRef}
className="overflow-auto h-[500px]"
style={{ contain: 'strict' }}
>
{/* Контент */}
</div>Изменение свойства top триггерит стадии Layout (Reflow) и Paint на каждый кадр скролла. Использование transform: translateY(...) выносит позиционирование на стадию Compositing (GPU), сохраняя стабильные 60/120 FPS.
// ❌ Антипаттерн: Layout Thrashing при каждом пикселе скролла
<div style={{ position: 'absolute', top: `${virtualRow.start}px` }}>
// ✅ Правильное решение: Аппаратное ускорение через GPU Composite
<div style={{ position: 'absolute', top: 0, left: 0, transform: `translateY(${virtualRow.start}px)` }}>