Статья · Личный блог

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

Окна рендера, измерение высот и скролл-якоря — что на самом деле нужно, чтобы листать тысячи элементов без лагов.

В Облаке Mail.ru пользователь может открыть папку с десятью тысячами файлов — и ждёт, что она будет листаться как обычный список. Рассказываю, что на самом деле нужно для плавного скролла: окно рендера, честное измерение высот и скролл-якоря.

DOM не бесконечный

Браузер спокойно держит пару тысяч простых узлов. Но строка файлового менеджера — это не один узел: иконка, название, метаданные, чекбокс, меню действий. Умножьте на 10 000 строк — и получите сотни тысяч элементов, которые надо стилизовать, перерисовывать и обсчитывать при каждом изменении раскладки.

Симптомы знакомы каждому: первая отрисовка на секунды, скролл с рывками, «зависшая» вкладка при выделении всех элементов. При этом на экране одновременно видно от силы 30 строк. Всё остальное — мёртвый груз.

Виртуализация — это договорённость с браузером: мы рисуем только то, что видно, а взамен обещаем правильно посчитать всё остальное.

Окно рендера

Базовая идея помещается в несколько строк: по позиции скролла вычисляем диапазон видимых индексов, рендерим только его и сдвигаем контейнер на высоту «пропущенных» строк.

// фиксированная высота строки — самый простой случай
function useWindow(count, rowH, viewportH, scrollTop) {
  const OVERSCAN = 6; // запас сверху и снизу
  const first = Math.floor(scrollTop / rowH) - OVERSCAN;
  const last = Math.ceil((scrollTop + viewportH) / rowH) + OVERSCAN;
  const start = Math.max(0, first);
  const end = Math.min(count, last);
  return { start, end, offsetY: start * rowH, totalH: count * rowH };
}

Контейнер получает height: totalH, чтобы скроллбар отражал реальный размер списка, а видимые строки — transform: translateY(offsetY). Оверскан в несколько строк убирает «мигание» пустоты при быстром скролле.

Готовые библиотеки — react-window, virtua, @tanstack/virtual — делают ровно это. Если высоты строк фиксированные, берите любую и не пишите свою. Сложности начинаются дальше.

Динамические высоты

Реальный контент не бывает одинаковой высоты: длинные названия переносятся на две строки, у картинок свои пропорции, между группами стоят заголовки. Заранее высоту не узнать — её можно только измерить после рендера.

Рабочая схема: храним оценку высоты для неизмеренных строк, а после монтирования уточняем её через ResizeObserver и пересчитываем позиции.

const measure = useCallback((node, index) => {
  if (!node) return;
  observer.observe(node);
  // сохраняем реальную высоту и помечаем кэш позиций грязным
  heights.current.set(index, node.offsetHeight);
  invalidateFrom(index);
}, []);

Главная ловушка — пересчитывать позиции всех строк при каждом измерении. На больших списках это O(n) на каждый кадр скролла. Решение: считать префиксные суммы лениво и кэшировать «чистый» диапазон — пересчитывается только хвост после изменившейся строки.

Скролл-якоря

Когда строка выше по списку меняет высоту — догрузилась картинка, развернулась группа, — всё содержимое под ней прыгает. Браузерный overflow-anchor с виртуализацией не работает: узлы, за которые он мог бы «зацепиться», мы сами же удаляем.

Якорь приходится держать руками: запоминаем индекс верхней видимой строки и её смещение относительно вьюпорта, а после пересчёта высот восстанавливаем scrollTop так, чтобы эта строка осталась на месте. Пользователь не должен замечать, что под ним «переехал» весь список.

Доступность

Виртуализация ломает то, на что полагаются скринридеры: в DOM существует только окно из ~40 строк. Минимум, который стоит сделать:

Чек-лист

Если коротко — вот что я проверяю, прежде чем считать виртуализованный список готовым:

В Облаке этот набор решений снял лаги при скролле папок на тысячи файлов и попутно срезал четверть initial JS — тяжёлые ячейки уехали в lazy-чанки, потому что их больше не нужно было рендерить «на всякий случай».