У сучасних вебзастосунках продуктивність відображення великих масивів даних обмежена не обчислювальною потужністю рушія 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} з data-index і позиціонуйте через transform: 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 зчитує точні розміри після застосування стилів і передає фактичну висоту в кеш віртуалізатора.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 та 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)` }}>