Почему большие списки убивают производительность?

Каждый DOM-элемент требует ресурсов:

  • Время рендеринга: React рекурсивно обходит компоненты, вычисляет разметку.
  • Память: Хранение экземпляров компонентов, DOM-узлов, слушателей событий.
  • Рефлоу/Репайнт: Изменения в списке запускают каскадные вычисления в браузере.

Попытка отрисовать 10 000 строк таблицы нередко приводит к блокировке UI потока на 10+ секунд. Вот простое решение, которое не работает:

javascript
// Плохая идея при 10k элементов:
const List = ({ items }) => (
  <ul>
    {items.map(item => <Item key={item.id} data={item} />)}
  </ul>
);

Стратегия 1: Ленивая загрузка данных (Lazy Loading)

Не грузите все сразу. Разбейте данные на страницы или порции:

javascript
const [items, setItems] = React.useState([]);
const [page, setPage] = React.useState(1);

const fetchMore = () => {
  fetch(`/api/data?page=${page}`)
    .then(res => res.json())
    .then(newItems => {
      setItems(prev => [...prev, ...newItems]);
      setPage(p => p + 1);
    });
};

// Используем Intersection Observer для детекции скролла
useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) fetchMore();
  }, { threshold: 0.1 });

  observer.observe(document.querySelector('#loader'));
  return () => observer.disconnect();
}, []);

Минусы:

  • Не решает проблему рендеринга при большом уже загруженном объёме данных.
  • Невозможна моментальная навигация (прыжок к 5000-й строке).

Стратегия 2: Виртуализация рендеринга

Отрендерить только то, что видит пользователь. Основные шаги:

  1. Рассчитать \высоту контейнера\ списка и \позицию скролла.
  2. Вычислить индексы элементов, попадающих во viewport.
  3. Отображать только их, остальные заменять padding.

Реализация своими руками:

jsx
const VirtualList = ({ items, itemHeight, containerHeight }) => {
  const containerRef = React.useRef();
  const [startIdx, setStartIdx] = React.useState(0);
  
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const innerHeight = items.length * itemHeight;

  const handleScroll = () => {
    const top = containerRef.current.scrollTop;
    const newStartIdx = Math.floor(top / itemHeight);
    setStartIdx(newStartIdx);
  };

  const visibleItems = items.slice(startIdx, startIdx + visibleCount);
  const offsetY = startIdx * itemHeight;

  return (
    <div ref={containerRef} style={{ height: containerHeight, overflowY: 'scroll' }} onScroll={handleScroll}>
      <div style={{ height: innerHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
          {visibleItems.map(item => (
            <Item key={item.id} height={itemHeight} data={item} />
          ))}
        </div>
      </div>
    </div>
  );
};

Критические оптимизации:

  • Троттлинг событий прокрутки: Используйте requestAnimationFrame или Lodash throttle для снижения частоты пересчётов.
  • Фиксированная vs. динамическая высота элемента:
    Если высота переменная, требуются сложные расчёты либо API наподобие ResizeObserver.

Библиотеки для сложных сценариев

Для динамических высот или горизонтальных списков:

  • react-window: Минималистичная, эффективная.
  • react-virtualized: Продвинутые фичи (авторазмеры, таблицы).
  • @tanstack/react-virtual (ex. react-virtual): Композиционный API, поддержка React 18.

Пример с реакт-виндоу:

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

const Row = ({ index, style }) => <div style={style}>Item {index}</div>;

const App = () => (
  <FixedSizeList
    height={600}   // Высота контейнера
    itemCount={10000}
    itemSize={50}   // Фиксированная высота элемента
    width="100%"
  >
    {Row}
  </FixedSizeList>
);

Edge Cases и нюансы

  1. Быстрота vs. точность: В виртуализации размер viewport часто искусственно увеличивают на 5-10% (overscan), чтобы избежать "мелькания" при скролле.
  2. Асинхронные данные: Если элементы запрашиваются с сервера, требуется предзагрузка соседних элементов.
  3. Мобильные устройства: Не забывайте про инерционный скролл — пассивные слушатели ({ passive: true }).
  4. React 18 и Concurrent Mode: Используйте startTransition для приоритизации рендеринга видимых элементов над фоновыми.

Когда виртуализация не нужна?

  • content-visibility: auto; (CSS): Современные браузеры откладывают рендеринг элементов за пределами viewport. Отлично работает для статичных списков.
  • Иерархическая разбивка: Если список имеет N уровней вложенности (например, дерево файлов), можно применять "ленивое" раскрытие узлов.

Вывод

Оптимизация списков — компромисс между сложностью реализации и требованиями UX. Начните с ленивой загрузки. Если загружено >500 элементов — внедряйте виртуализацию. Используйте готовые решения, но понимайте их границы: для списков с динамической высотой элементов react-virtualized даст бóльшую гибкость, чем react-window. Финальный замер — профилирование в React DevTools и Chrome Performance. Скроллинг свыше 60 FPS достижим для 100k записей при правильном подходе.