Виртуализация длинных списков

Виртуализация (windowing) — техника рендеринга только видимых элементов длинного списка: в DOM находится ~20-30 строк вместо тысяч, что радикально сокращает потребление памяти и время рендеринга.

Зачем нужно

Рендеринг 10 000 DOM-элементов создаёт серьёзную нагрузку на память и замедляет скролл. Виртуализация держит в DOM только ~30 видимых элементов + буфер: скролл плавный, память под контролем, React reconciliation быстрый.

Где используется

  • Таблицы с тысячами строк (логи, транзакции, отчёты)
  • Бесконечные ленты (соцсети, чат-история)
  • Выпадающие списки с тысячами опций (комбобоксы)
  • Файловые менеджеры и tree view

Основной контент

Концепция windowing

Реальный DOM:                Виртуализированный DOM:
┌─────────────┐              ┌─────────────┐
│ Item 1      │              │ spacer (top)│ ← высота отсутствующих эл.
│ Item 2      │              │ Item 6      │
│ Item 3      │              │ Item 7      │ ← только видимые
│ ...         │              │ Item 8      │
│ Item 10000  │              │ Item 9      │
└─────────────┘              │ spacer(bot) │
10000 DOM nodes!             └─────────────┘
                             ~10 DOM nodes!

react-virtual (TanStack Virtual)

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

function VirtualList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement:  => parentRef.current,
    estimateSize:  => 60, // Предполагаемая высота строки (px)
    overscan: 5,            // Дополнительные элементы вне viewport
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Контейнер с полной высотой для правильного scrollbar */}
      <div style={{ height: virtualizer.getTotalSize + 'px', position: 'relative' }}>
        {virtualizer.getVirtualItems.map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: virtualItem.start() + 'px',
              width: '100%',
              height: virtualItem.size + 'px',
            }}
          >
            <ListItem item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

react-window (альтернатива для фиксированных размеров)

import { FixedSizeList } from 'react-window';

const Row = ({ index, style, data }) => (
  <div style={style} className="list-row">
    {data[index].name}
  </div>
);

function SimpleList({ items }) {
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={60}    // Высота строки в px
      itemData={items}
    >
      {Row}
    </FixedSizeList>
  );
}

Виртуализация с переменной высотой

import { VariableSizeList } from 'react-window';
import { useCallback, useRef } from 'react';

function VariableList({ items }) {
  const listRef = useRef(null);
  const rowHeights = useRef({});

  const getItemSize = useCallback(
    index => rowHeights.current[index] ?? 80,
    
  );

  const setRowHeight = useCallback((index, size) => {
    rowHeights.current = { ...rowHeights.current, [index]: size };
    listRef.current?.resetAfterIndex(index);
  }, );

  return (
    <VariableSizeList
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      estimatedItemSize={80}
    >
      {({ index, style }) => (
        <MeasuredRow
          style={style}
          item={items[index]}
          onHeightChange={h => setRowHeight(index, h)}
        />
      )}
    </VariableSizeList>
  );
}

Частые ошибки

  • Виртуализация при < 100 элементах — overhead не оправдан, content-visibility: auto достаточно
  • Отсутствие key на виртуальных элементах — React неправильно сопоставляет компоненты
  • Динамическая высота без resetAfterIndex — некорректное позиционирование
  • Вложенный скролл внутри виртуализированного списка — конфликт scroll handlers

Связанные темы

Ресурсы