lenec ru

← все посты

Виртуализация длинных списков в React: TanStack Virtual без боли

15K

Нам прилетел тикет — оптимизировать листинг на 5К карточек товаров. Без виртуализации страница на iPhone 11 умирала в момент скролла, FPS падал до 5–7. С виртуализацией стала держаться на 58–60. Звучит как магия. На деле — это полтора часа работы и одна правильно подобранная библиотека.

Пройдусь по тому, как я делаю виртуальный список в реальных проектах, какие грабли встречаются, и почему я выбрала TanStack Virtual для всех новых задач, хотя начинала с react-window.

Что такое виртуализация в одну фразу

Ты рендеришь не все 5000 элементов списка, а только те 20–30, которые сейчас видны в окне просмотра. Когда пользователь скроллит, ты подменяешь их на следующие. Браузер думает, что список длинный (за счёт высокого spacer-блока), но в DOM в каждый момент времени всего пара десятков элементов.

Звучит просто. Сложности начинаются на динамических высотах, sticky-заголовках, горизонтальной прокрутке и вложенных скроллах. Но базовый случай реально решается за вечер.

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

Не надо делать виртуализацию всегда и везде. Это не бесплатно: добавляется библиотека, усложняется код, тяжелее тестировать e2e, иногда ломается accessibility. Я ставлю виртуализацию, если:

  • В списке больше 200–300 элементов и каждый рендерится дороже простого li.
  • Список будет расти (бесконечная прокрутка с подгрузкой).
  • Профайлер реально показывает, что DOM-операции тормозят, а не сеть или вычисления.

Если у тебя 50 строк простой таблицы — виртуализация не нужна. Современный React с минимальной мемоизацией с этим справится.

Почему TanStack Virtual, а не react-window

Я писала и на react-window, и на react-virtualized, и на own-rolled решении. TanStack Virtual выиграл по трём пунктам:

  1. API на хуках, без HOC и render-props.
  2. Полная типизация под TS-строгий режим из коробки.
  3. Динамические высоты работают без отдельного CellMeasurer.

Минусы тоже есть. Он чуть менее популярен, чем react-window, и в сообществе на StackOverflow ответов меньше. Но за полтора года жизни с ним я не встретила ситуации, в которой бы пришлось переключаться обратно.

Минимальный пример с фиксированной высотой

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

type Item = { id: string; title: string };

export function ProductList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 64,
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: "100vh", overflow: "auto" }}
    >
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          position: "relative",
        }}
      >
        {rowVirtualizer.getVirtualItems().map((row) => {
          const item = items[row.index];
          return (
            <div
              key={row.key}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: row.size,
                transform: `translateY(${row.start}px)`,
              }}
            >
              {item?.title}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Что важно: высота родительского контейнера должна быть конечной. Не 100% от чего-то неопределённого, а реальное число или 100vh с правильным layout. Иначе виртуализатор не сможет посчитать видимый диапазон.

Динамические высоты: где обычно ломаются

Если у тебя карточки разной высоты (текст разной длины, картинки разных размеров), estimateSize возвращает приблизительное значение. Реальная высота измеряется через measureElement:

{rowVirtualizer.getVirtualItems().map((row) => (
  <div
    key={row.key}
    data-index={row.index}
    ref={rowVirtualizer.measureElement}
    style={{
      position: "absolute",
      top: 0,
      left: 0,
      width: "100%",
      transform: `translateY(${row.start}px)`,
    }}
  >
    <Card item={items[row.index]!} />
  </div>
))}

Грабля номер один: забыть data-index={row.index}. Без него measureElement не понимает, какому элементу относится размер, и ты получаешь хаотичные прыжки во время скролла.

Грабля номер два: ставить height в стилях ячейки в самой обёртке. Тогда виртуализатор не сможет измерить реальную высоту, потому что ты её сам жёстко задал.

Изображения внутри карточек

Это самая частая причина «прыгающих» списков. Картинка грузится, размер меняется, виртуализатор пересчитывает высоты, остальные элементы прыгают.

Решение прямолинейное — резервировать место под картинку aspect-ratio или явной высотой контейнера:

function ProductCard({ item }: { item: Item }) {
  return (
    <article>
      <div style={{ aspectRatio: "4 / 3", background: "#eee" }}>
        <img src={item.image} alt="" loading="lazy" />
      </div>
      <h3>{item.title}</h3>
    </article>
  );
}

Теперь высота карточки не зависит от того, загрузилась картинка или нет. Виртуализатор спокоен.

Бесконечная прокрутка с подгрузкой

TanStack Virtual отлично сочетается с TanStack Query (или SWR) для бесконечного скролла. Ловишь момент, когда последний видимый элемент близок к концу загруженных данных, дёргаешь fetchNextPage:

const items = data?.pages.flatMap((p) => p.items) ?? [];

const rowVirtualizer = useVirtualizer({
  count: hasNextPage ? items.length + 1 : items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 64,
});

useEffect(() => {
  const last = rowVirtualizer.getVirtualItems().at(-1);
  if (!last) return;
  if (last.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
    fetchNextPage();
  }
}, [
  rowVirtualizer.getVirtualItems(),
  items.length,
  hasNextPage,
  isFetchingNextPage,
  fetchNextPage,
]);

Идея: виртуальный список показывает на один элемент больше, чем загружено. Когда пользователь до него доскроллил — догружаем страницу. Без блокирующего лоадера на весь экран.

Sticky-заголовки внутри виртуального списка

Это место, где люди часто решают, что виртуализация «не подходит». Подходит, просто чуть сложнее. У TanStack Virtual есть прямая поддержка через rangeExtractor: ты говоришь, какие индексы должны быть всегда отрендерены, и они будут оставаться в DOM. Дальше через CSS делаешь их sticky.

На практике для списка-каталога с группировкой по категориям я обычно делаю отдельные элементы-заголовки в массиве данных и помечаю их как «всегда видимые» через rangeExtractor. Не идеально, но работает стабильно.

Доступность: что не сломать

Виртуальный список ломает поведение клавиатурной навигации и скринридеров, если делать его в лоб. Скринридер не видит элементов, которых нет в DOM. Tab уходит в никуда.

Что я делаю минимально:

  • Корневой контейнер — role="listbox" или role="grid", в зависимости от семантики.
  • Каждый видимый элемент — role="option" с aria-setsize="{общее_количество}" и aria-posinset="{реальный_индекс_в_массиве}".
  • Управление фокусом по клавишам стрелок — отдельный обработчик, который скроллит к нужному элементу через rowVirtualizer.scrollToIndex.

Это не делает виртуальный список так же доступным, как обычный, но даёт скринридеру понимание, что в списке всего N элементов и сейчас виден промежуток с такого-то по такой-то.

Что я обычно проверяю на готовом виртуальном списке

  1. Скролл-перформанс на mid-tier мобилке. Не на M2 Pro.
  2. Поведение при ресайзе окна. У некоторых решений ломается, если ширина меняется на лету.
  3. Поиск и фильтрация. Когда в массиве остаётся 0 элементов или 1 — нет ли пустого скролла.
  4. Восстановление позиции скролла после возврата на страницу.
  5. Tab/Shift+Tab проходят корректно по интерактивным элементам внутри карточек.

Из этих пяти пунктов я чаще всего падаю на четвёртом. Если возвращаешься на страницу с виртуальным списком, а позиция сбросилась в начало — пользователь злится. Решается через scrollToIndex в эффекте после маунта плюс сохранение индекса в URL или sessionStorage.

Что запомнить

Виртуализация — не «шаманство», а конкретный набор техник. Для большинства задач хватает TanStack Virtual с фиксированной или динамической высотой через measureElement. Начинай с минимального примера и наслаивай: сначала просто список, потом картинки с резервированием места, потом бесконечная подгрузка, потом sticky-заголовки.

И не делай виртуализацию там, где её не просили. Если у тебя 80 элементов и страница не тормозит — оставь обычный map. Сэкономишь время на дебаг и не сломаешь accessibility ради эстетики.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.