Коротко: Разбираем архитектуру TanStack Virtual v3 и строгую типизацию useVirtualizer в TypeScript: дженерики TScrollElement и TItemElement, динамические замеры.
Рендеринг массивов из десятков тысяч элементов — классическая точка отказа для интерфейсов на React. При прямой отрисовке 10 000+ DOM-узлов браузер сталкивается с резким ростом расхода оперативной памяти, задержками при пересчете геометрии страницы (layout recalculation) и просадками частоты кадров при прокрутке.
Виртуализация решает эту проблему за счет техники Windowing: в DOM-дереве одновременно присутствуют только те элементы, которые попадают в видимую область экрана (viewport), плюс небольшой буфер. Библиотека TanStack Virtual (v3) предоставляет headless-подход к виртуализации с глубокой интеграцией в TypeScript.
Архитектура TanStack Virtual: почему Headless и Framework-Agnostic
В отличие от компонентов с предустановленной версткой, TanStack Virtual не навязывает жесткую структуру DOM-дерева. Это headless-библиотека, которая рассчитывает координаты, отступы и размеры элементов, оставляя рендеринг и стилизацию на стороне разработчика.
Разделение на @tanstack/virtual-core и @tanstack/react-virtual
Архитектура библиотеки разделена на два изолированных слоя:
@tanstack/virtual-core— независимое ядро на чистом TypeScript. В нем содержится классVirtualizer, алгоритмы бинарного поиска видимых индексов, менеджмент скролла, кэш замеров и система подписок на изменения размера контейнера (ResizeObserver).@tanstack/react-virtual— адаптер для React. Он инкапсулирует вызовы ядра в хукuseVirtualizer, связывает жизненный цикл инстансаVirtualizerс фазами рендера React и триггерит обновление только при изменении видимого диапазона элементов.
Принцип работы Virtual Window
Логика виртуализации базируется на взаимодействии ключевых структурных блоков:
- Scroll Container (Viewport) — внешний элемент с фиксированной высотой и CSS-свойством
overflow: auto. - Total Size Wrapper — внутренний контейнер, высота (или ширина) которого вычисляется методом
virtualizer.getTotalSize(). Он создает иллюзию полноценного длинного контента для нативной полосы прокрутки. - Virtual Items — массив элементов, возвращаемый методом
virtualizer.getVirtualItems(). Каждый элемент позиционируется абсолютно с помощьюtransform: translateY(...), что переносит расчет сдвигов на GPU и предотвращает тяжелые перерисовки (reflow). - Параметр
overscan— задает количество дополнительных элементов, рендерящихся за пределами viewport сверху и снизу. Это предотвращает появление белых пустых областей при быстром скролле.
Погружение в типы: Generic-параметры Virtualizer<TScrollElement, TItemElement>
Основой библиотеки выступает класс Virtualizer, параметризованный двумя дженериками:
export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
constructor(options: VirtualizerOptions<TScrollElement, TItemElement>);
options: readonly Required<VirtualizerOptions<TScrollElement, TItemElement>>;
// ...
}
Назначение TScrollElement и TItemElement
TScrollElement— тип DOM-элемента, реализующего прокрутку. Чаще всего этоHTMLDivElementилиWindow(при виртуализации относительно окна браузера). Этот дженерик строго определяет сигнатуру функцииgetScrollElement: () => TScrollElement | null.TItemElement— тип отдельного измеряемого DOM-узла внутри списка (HTMLDivElement,HTMLTableRowElement,HTMLLIElementи др.). Он используется в методеmeasureElement(element: TItemElement | null)для динамического замера габаритов ноды черезResizeObserver.
Анатомия интерфейса VirtualItem
Метод getVirtualItems() возвращает массив объектов типа VirtualItem:
export interface VirtualItem {
key: Key; // Уникальный ключ элемента (по умолчанию равен index)
index: number; // Индекс элемента в исходном массиве данных
start: number; // Смещение элемента от начала контейнера в пикселях
end: number; // Конечная координата элемента (start + size)
size: number; // Высота или ширина элемента
lane: number; // Индекс дорожки (используется для multi-column/grid раскладок)
}
Благодаря этим полям компонент получает строгие координаты для позиционирования каждого дочернего узла.
Строгая типизация useVirtualizer на практике
Рассмотрим реализацию виртуализированного списка с типизацией контейнера и элементов.
Конфигурация обязательных опций VirtualizerOptions
Хук useVirtualizer требует передачи объекта конфигурации с тремя обязательными свойствами:
count: number— общее число элементов списка;getScrollElement: () => TScrollElement | null— геттер DOM-ноды скролл-контейнера;estimateSize: (index: number) => number— функция, возвращающая приблизительный размер элемента в пикселях.
import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
interface User {
id: string;
name: string;
role: string;
}
interface VirtualListProps {
items: User[];
}
export const VirtualList: React.FC<VirtualListProps> = ({ items }) => {
// 1. Создаем строго типизированный ref для скролл-контейнера
const parentRef = useRef<HTMLDivElement | null>(null);
// 2. Передаем дженерики TScrollElement и TItemElement в хук
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // Базовая оценка высоты строки (64px)
overscan: 5,
getItemKey: (index) => items[index]?.id ?? index,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<div
ref={parentRef}
style={{
height: '500px',
overflowY: 'auto',
border: '1px solid #e2e8f0',
position: 'relative',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = items[virtualRow.index];
if (!item) return null;
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div>{item.name} — {item.role}</div>
</div>
);
})}
</div>
</div>
);
};
Типизация динамической высоты и measureElement
Если элементы имеют непредсказуемый объем контента (например, сообщения в чате или аккордеоны), одного estimateSize недостаточно. В этом случае TanStack Virtual использует динамическое измерение нод через метод measureElement и data-атрибут data-index.
import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
interface DynamicArticle {
id: string;
title: string;
body: string;
}
export const DynamicVirtualList: React.FC<{ articles: DynamicArticle[] }> = ({ articles }) => {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: articles.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 100, // Минимальная или усредненная оценка
overscan: 4,
getItemKey: (index) => articles[index]?.id ?? index,
});
return (
<div
ref={scrollContainerRef}
style={{ height: '600px', overflowY: 'auto', position: 'relative' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const article = articles[virtualRow.index];
if (!article) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // Подключаем ResizeObserver к DOM-элементу
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div style={{ padding: '16px', borderBottom: '1px solid #ddd' }}>
<h3>{article.title}</h3>
<p>{article.body}</p>
</div>
</div>
);
})}
</div>
</div>
);
};
При передаче ref={virtualizer.measureElement} ядро виртуализатора считывает фактическую высоту через ResizeObserver, обновляет внутренний кэш смещений и динамически корректирует координаты всех последующих элементов без рывков интерфейса.
Тонкости TypeScript и распространенные ошибки
Интеграция виртуализации в крупные enterprise-проекты часто сопровождается неочевидными архитектурными ошибками на стыке типов React и DOM API.
Обработка null на этапе первого рендера
При первом вызове компонента useRef возвращает ref.current === null, так как реальный DOM-узел еще не смонтирован в дерево.
Сигнатура getScrollElement: () => TScrollElement | null штатно обрабатывает возврат null. Не следует принудительно подавлять проверки типов через non-null assertion (parentRef.current!), так как это маскирует реальное состояние жизненного цикла и может привести к ошибкам в SSR-окружениях (Next.js, Remix).
Горизонтальные списки и сетки (Grids)
По умолчанию useVirtualizer работает в вертикальном режиме (horizontal: false). Для горизонтальных списков требуется явно указать ориентацию:
const horizontalVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150,
horizontal: true,
});
В таком сценарии virtualRow.start задает смещение по оси X, а для позиционирования используется transform: translateX(${virtualRow.start}px).
Для двумерных сеток (Grid) создаются два независимых инстанса useVirtualizer — один для строк (rowVirtualizer), второй для колонок (columnVirtualizer), использующих общий скролл-контейнер parentRef.
Реактивность и неизменяемость опций
Объект настроек внутри инстанса Virtualizer хранится в свойстве со строгим типом:
options: readonly Required<VirtualizerOptions<TScrollElement, TItemElement>>
Если параметры списка меняются динамически (например, count уменьшается при клиентской фильтрации или пересчитывается estimateSize), хук useVirtualizer отслеживает эти изменения на каждом рендере и синхронизирует состояние ядра.
Однако при мутации массива данных без создания новой ссылки или при передаче нестабильных ссылок анонимных функций в getItemKey может возникнуть рассинхронизация кэша замеров (measurementsCache). Для принудительной очистки устаревших замеров при резкой смене структуры данных используйте метод virtualizer.measure().
Часто задаваемые вопросы (FAQ)
Зачем явно передавать дженерики TScrollElement и TItemElement в useVirtualizer?
Явная передача типов предотвращает неявное выведение generic-параметров как unknown или Element. Это гарантирует, что measureElement принимает строго ожидаемые DOM-ноды (например, HTMLDivElement), а getScrollElement возвращает совместимый элемент контейнера, исключая ошибки несоответствия типов при работе с useRef.
Как правильно типизировать estimateSize, если элементы имеют разную высоту?
Функция имеет строгую сигнатуру (index: number) => number. Если элементы имеют разный тип контента, внутри функции можно по индексу обратиться к метаданным объекта в массиве и вернуть ориентировочную высоту для конкретного типа записи. Точный размер затем будет зарегистрирован через measureElement.
Почему getScrollElement возвращает null на первом рендере?
React наполняет объект ref.current ссылкой на DOM-ноду только после завершения первой фазы рендеринга и монтирования узла. Типизация TanStack Virtual изначально спроектирована под контракт TScrollElement | null, поэтому ядро ожидает появления ноды и запускает расчеты сразу после ее готовности.
В чем разница между VirtualItem['key'] и VirtualItem['index']?
Поле index отражает порядковый номер элемента в исходном массиве данных (от 0 до count - 1). Поле key — это уникальный идентификатор для алгоритма согласования React (Reconciliation). По умолчанию key совпадает с index, но при сортировке, фильтрации или удалении записей необходимо определять getItemKey: (index) => id, чтобы React не пересоздавал DOM-узлы без необходимости.
Поддерживает ли TanStack Virtual работу с SSR (Server-Side Rendering)?
Да. На сервере getScrollElement возвращает null, и хук возвращает начальное расчетное состояние на базе count и estimateSize. Для предотвращения сдвигов макета (Cumulative Layout Shift) при гидратации на клиенте важно передавать максимально реалистичные значения в функцию estimateSize.
Заключение
TanStack Virtual предоставляет гибкий headless-инструмент для построения высокопроизводительных списков, таблиц и сеток. Строгая типизация через дженерики TScrollElement и TItemElement делает кодовую базу устойчивой к рефакторингу, исключает runtime-ошибки при работе с DOM API и упрощает проектирование переиспользуемых UI-компонентов в дизайн-системах на React и TypeScript.




.svg.webp)



