Виртуализация списков на фронтенде: экономия ресурсов без потерь функциональности

Проблема:

jsx
function UserList() {
  const users = fetchAllUsers(); // 10,000+ записей
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Этот код может привести к катастрофе производительности. Каждый элемент UserCard, даже невидимый пользователю, создаёт DOM-ноду, потребляет память и загружает поток рендеринга.

Принцип виртуализации:
Отображать только видимую часть контента + небольшой буфер сверху/снизу. При прокрутке - динамически заменять элементы. Результат:

  • Фиксированное количество DOM-элементов независимо от размера данных
  • Нагрузка на GPU вместо CPU
  • Плавная прокрутка на мобильных устройствах

Реализуем базовую виртуализацию

Шаг 1: Определяем видимую область

jsx
const VirtualList = ({ items, itemHeight, buffer = 5 }) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);
  
  useEffect(() => {
    const handler = () => setScrollTop(containerRef.current.scrollTop);
    containerRef.current.addEventListener('scroll', handler);
    return () => containerRef.current.removeEventListener('scroll', handler);
  }, []);
  
  const viewportHeight = containerRef.current?.clientHeight || 0;
  // ...
};

Шаг 2: Рассчитываем видимые индексы

javascript
const innerHeight = items.length * itemHeight;
const startIndex = Math.max(
  0, 
  Math.floor(scrollTop / itemHeight) - buffer
);
const endIndex = Math.min(
  items.length - 1,
  startIndex + Math.ceil(viewportHeight / itemHeight) + buffer * 2
);

const visibleItems = items.slice(startIndex, endIndex + 1);

Шаг 3: Перемещаем контент с трансформацией

jsx
<div 
  ref={containerRef}
  style={{ height: '100vh', overflowY: 'auto' }}
>
  <div style={{ height: `${innerHeight}px`, position: 'relative' }}>
    {visibleItems.map((item, index) => (
      <div
        key={item.id}
        style={{
          position: 'absolute',
          top: `${(startIndex + index) * itemHeight}px`,
          width: '100%'
        }}
      >
        <UserCard user={item} />
      </div>
    ))}
  </div>
</div>

Замеры производительности до/после

ПараметрБез виртуализации (10K элементов)С виртуализацией
DOM nodes10200+50
Время рендеринга3200 мс8 мс
Потребление памяти380 МБ85 МБ
FPS при скролле3-458-60

Ключевые оптимизации

Фиксированная vs. динамическая высота

jsx
// Для элементов переменной высоты:
const measuredRef = useRef();

<Item 
  ref={measuredRef}
  onMeasure={(height) => updateItemHeight(index, height)}
/>
  • Используйте ResizeObserver
  • Кешируйте измерения
  • Асинхронный рендеринг после измерений

Чёткие и размытые трансформации

css
.will-change-transform {
  will-change: transform;
  backface-visibility: hidden;
}

Без этой оптимизации анимация прокрутки может прерываться при смене элементов.

Асинхронный рендеринг при быстрой прокрутке

javascript
let scrollTimeout;

const handleScroll = () => {
  clearTimeout(scrollTimeout);
  
  // Показать индикатор загрузки при быстром скролле
  if (isFastScroll) showSkeleton();
  
  scrollTimeout = setTimeout(() => {
    updateVisibleItems();
    hideSkeleton();
  }, 50);
};

Когда и почему виртуализация вредна

  1. Слишком маленькие списки (<100 элементов) - оверхед превышает выгоду
  2. Таблицы с фоновыми вычислениями - виртуализация может нарушить структуру таблиц
  3. Контент со вложенными прокрутками - конфликтует с родительским scroll container

Альтернативы: CSS vs JS

CSS Containment

css
.container {
  content-visibility: auto;
  contain-intrinsic-size: 400px; /* Оценочная высота */
}

Плюсы:

  • Нативные браузерные оптимизации
  • Нет логики на JavaScript

Минусы:

  • Не поддерживает пользовательские буферы
  • Ограниченный контроль над поведением
  • Отображение неизмеренного содержимого "блэкауты"

Реальные кейсы из производства

Кейс 1: Фильтрация и виртуализация
При фильтрации 50K записей:

  • Исходное решение: 3000мс до отклика
  • Оптимизация:
    javascript
    useMemo(() => {
      // Предвычисление видимых индексов
      return largeDataSet.filter(fn).slice(0, 100);
    }, [deps]);
    

Результат: обновление за 110мс

Кейс 2: Анимации в виртуальном списке
Проблема: анимации схлопывания нарушались при уходе элементов из области видимости. Решение:

jsx
<AnimatePresence>
  {visibleItems.map(item => (
    <motion.div
      key={item.id}
      exit={{ height: 0 }}
      style={{ position: 'absolute', top: position }}
    >
      <Content />
    </motion.div>
  ))}
</AnimatePresence>

Когда использовать библиотеки

Библиотеки типа React-Window и React-Virtualized решают:

  • Поддержка горизонтальной виртуализации
  • Динамические размеры элементов
  • Прерываемый рендеринг
  • Разделение измерений и рендеринга

Экономия памяти в Action

jsx
import { FixedSizeList } from 'react-window';

<List
  height={600}
  itemCount={100000}
  itemSize={75}
  width={300}
>
  {({ index, style }) => (
    <div style={style}>
      Пользователь #{index}
    </div>
  )}
</List>

Глубокое погружение: Windowed vs. контекстное решение

Windowed (traversal virtualization)

  • Физически перемещаем DOM-ноды
  • Проблемы: сброс состояния компонентов

Контекстное (render virtualization)

jsx
{isLoading ? (
  <Skeleton />
) : (
  <RealContent />
)}
  • Идеально для сохранения состояния
  • Требует стабильных ключей

Перспективы: новое в браузерах

Скролл-анимации с View-Transitions API

css
::view-transition-old(list-item),
::view-transition-new(list-item) {
  height: auto;
  mix-blend-mode: normal;
}

Container Queries + Virtualization
Автоматическое изменение структуры при уменьшении контейнера:

css
.card-container {
  container-type: inline-size;
}

@container (max-width: 350px) {
  .card {
    flex-direction: column;
  }
}

Заключение: чек-лист внедрения

  1. Замерьте производительность до оптимизации
    React DevTools Profiler, Chrome Performance tab

  2. Выберите стратегию: │ ├── <100 элементов → CSS content-visibility
    ├── Таблицы → Библиотеки для таблиц
    └── Кастомный UI → Реализация или react-window

  3. Оркеструйте состояние:

    javascript
    // Сохраняйте состояние вне отображаемых элементов
    const [, setScrollTop] = useState();
    
  4. Тестируйте пограничные случаи:

    • Экстренно быстрая прокрутка
    • Резкое изменение размеров окна
    • Тяжёлые пользовательские компоненты
    • Динамические подгрузки данных
  5. Анализ после внедрения:

    • Lighthouse score для FCP, TBT
    • Heap snapshots
    • Input delay при скролле

Итог: Виртуализация — это не просто выбор между "импорт_библиотеки" и "написать_самому". Это архитектурное решение, требующее понимания механизмов браузерного рендеринга. Современные инструменты позволяют комбинировать нативные возможности CSS и JavaScript для создания плавных интерфейсов с тысячами элементов.