Статья · Личный блог
Виртуализация длинных списков в 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 так, чтобы эта строка осталась на месте. Пользователь не должен замечать, что под ним «переехал» весь список.
- Якорь обновляется только от действий пользователя — скролла и навигации с клавиатуры.
- Программные изменения (догрузка данных, ресайз) якорь не двигают — они подстраиваются под него.
- Восстановление
scrollTopделаем в том же кадре, что и пересчёт, — иначе один кадр «дребезга» всё равно виден.
Доступность
Виртуализация ломает то, на что полагаются скринридеры: в DOM существует только окно из ~40 строк. Минимум, который стоит сделать:
role="listbox"илиrole="grid"на контейнере иaria-rowcountс реальным размером списка;aria-rowindexна каждой видимой строке — так скринридер объявляет «строка 4 211 из 10 000»;- фокус-менеджмент: стрелки двигают активный индекс, а не DOM-фокус по узлам, которые вот-вот размонтируются;
Ctrl+A,Home/End,PageUp/PageDownработают с данными, а не с DOM.
Чек-лист
Если коротко — вот что я проверяю, прежде чем считать виртуализованный список готовым:
- скроллбар отражает реальный размер списка, а не окна;
- быстрый скролл «маховиком» не показывает пустых участков;
- изменение высоты строки выше вьюпорта не сдвигает видимый контент;
- пересчёт позиций не входит в горячий путь скролла (проверено профайлером);
- клавиатурная навигация и скринридер знают о полном размере списка;
- при 10× росте данных деградирует память, но не FPS.
В Облаке этот набор решений снял лаги при скролле папок на тысячи файлов и попутно срезал четверть initial JS — тяжёлые ячейки уехали в lazy-чанки, потому что их больше не нужно было рендерить «на всякий случай».