Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Виртуализация больших списков: разбираемся в типизации и архитектуре TanStack Virtual

Тимофей Ищенко

Коротко: Разбираем архитектуру 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

Архитектура библиотеки разделена на два изолированных слоя:

  1. @tanstack/virtual-core — независимое ядро на чистом TypeScript. В нем содержится класс Virtualizer, алгоритмы бинарного поиска видимых индексов, менеджмент скролла, кэш замеров и система подписок на изменения размера контейнера (ResizeObserver).
  2. @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 требует передачи объекта конфигурации с тремя обязательными свойствами:

  1. count: number — общее число элементов списка;
  2. getScrollElement: () => TScrollElement | null — геттер DOM-ноды скролл-контейнера;
  3. 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.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также