Виртуализация длинных списков: как устроено внутри
Список на 50 000 строк вешает вкладку: браузер держит в DOM 200 000 узлов. Виртуализация оставляет в дереве только видимое. Разбираем, что происходит на скролле, где текут баги и что мерить.

Таблица на 50 000 строк по четыре ячейки — это 200 000 DOM-узлов плюс обёртки. Chrome на такой странице тратит секунды на первичный layout, скролл дёргается, вкладка съедает 400–600 МБ. Виртуализация решает проблему грубо: в дереве живут только те строки, что попадают в видимую область плюс небольшой запас сверху и снизу. Остальные не существуют как DOM. Разберём, что именно происходит на каждом кадре скролла, почему без фиксированной высоты всё усложняется и где типичные реализации ломаются под реальными данными.
Речь про клиентскую виртуализацию в браузере: React, Vue или ванильный JS — механизм один. Примеры даю на React с @tanstack/react-virtual (v3.x), но идеи переносятся на react-window, Vue Virtual Scroller и собственные реализации.
Что держит браузер и почему это дорого
Каждый DOM-узел — это память под сам узел, под привязанные обработчики событий и под вычисленные стили. Когда узлов десятки тысяч, дорого стоит не только первичный рендер, но и любой reflow: изменили ширину контейнера — браузер пересчитывает геометрию всех потомков. Именно поэтому длинный список тормозит не в момент прокрутки, а при ресайзе окна и при вставке новой строки в середину.
Виртуализация меняет постановку задачи. Вместо «отрендерить всё и позволить браузеру скроллить нативно» вы говорите: «в любой момент времени в DOM находится N строк, где N зависит от высоты вьюпорта, а не от размера данных». Для списка на 50 000 элементов при высоте окна 800px и строке 40px в дереве живёт около 20 строк плюс запас — то есть 25–30 узлов вместо 50 000.
Три части механизма
Любая виртуализация состоит из трёх кусков, как бы её ни называла конкретная библиотека:
- Скролл-контейнер — элемент с
overflow: autoи реальной высотой. Он даёт нативную полосу прокрутки и событиеscroll. - Распорка (spacer) — внутренний элемент, высота которого равна суммарной высоте всех строк, даже несуществующих. Именно он заставляет скроллбар вести себя так, будто в списке все 50 000 элементов.
- Окно рендера — абсолютно спозиционированные строки, которые сдвигаются через
transform: translateY(...)при скролле.
Распорка — ключевая деталь. Без неё скроллбар отражал бы высоту только видимых строк, и прокрутить список было бы некуда.
Что происходит на одном кадре скролла
Пользователь крутит колесо. Браузер меняет scrollTop контейнера и стреляет событием scroll. Дальше начинается работа виртуализатора:
- Считывается текущий
scrollTop— сколько пикселей ушло вверх. - По высоте строки вычисляется индекс первого видимого элемента:
startIndex = floor(scrollTop / rowHeight). - По высоте вьюпорта — сколько строк влезает:
visibleCount = ceil(viewportHeight / rowHeight). - К диапазону добавляется overscan — по нескольку строк сверху и снизу, чтобы при быстром скролле не мелькала пустота.
- Формируется массив видимых индексов, каждому строке присваивается
translateY = index * rowHeight, и фреймворк перерисовывает только эти узлы.
Событие scroll летит десятки раз в секунду, поэтому расчёт обязан быть дешёвым: арифметика и один reconciliation-проход по 20–30 элементам. Тяжёлая работа внутри обработчика скролла — прямой путь к джанку.
Нативный скролл нельзя блокировать. Всё, что делает виртуализатор, должно укладываться в один кадр — 16 мс при 60 fps. Если расчёт видимого диапазона занимает больше, скролл начинает отставать от пальца, и это заметно сразу.
Фиксированная высота — простой случай
Когда все строки одинаковой высоты, позиция любой из них — это умножение. Библиотека не трогает DOM, чтобы что-то измерить, вся геометрия считается формулой. Минимальный пример на TanStack Virtual:
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
function FixedList({ rows }) {
// Ссылка на скролл-контейнер — из него читается scrollTop
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: rows.length, // всего элементов, включая невидимые
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // высота строки в пикселях
overscan: 5, // запас строк за пределами вьюпорта
})
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
{/* Распорка задаёт полную высоту списка */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: item.size,
transform: `translateY(${item.start}px)`,
}}
>
{rows[item.index].label}
</div>
))}
</div>
</div>
)
}
Обратите внимание: getTotalSize() возвращает rows.length * 40, и эта высота уходит в распорку. А item.start — это готовый offset для translateY, библиотека посчитала его за вас.
Динамическая высота — где всё усложняется
Если строки разной высоты (карточки с текстом переменной длины), формула не работает: вы не знаете реальную высоту, пока не отрендерите и не измерите элемент. Здесь виртуализатор переходит на измерение по факту.
Схема такая: сначала используется оценка (estimateSize), строка рендерится, после монтирования её реальная высота считывается через ResizeObserver или measureElement, значение кешируется по индексу, а суммарная высота и offset'ы пересчитываются. В TanStack Virtual к строке привязывается измеритель:
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement} // библиотека сама измерит узел
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
>
{rows[item.index].content}
</div>
Плата за динамику — layout shift. Пока строка не измерена, её оценка может отличаться от реальной высоты, и после измерения нижележащие элементы сдвигаются. При скролле вверх, к уже отрендеренным строкам, это выглядит как прыжок содержимого. Библиотеки борются с этим корректировкой scrollTop после измерения, но полностью проблему на произвольных данных не убирает никто.
Overscan: почему запас нельзя ставить большим
Overscan — количество строк, которые рендерятся за границами вьюпорта. Ноль overscan означает, что при быстром скролле пользователь на долю секунды видит белую полосу: строка ещё не отрендерилась. Большой overscan убирает мелькание, но возвращает часть проблемы, ради которой всё затевалось — в DOM снова много узлов.
Разумный диапазон — 3–10 строк в зависимости от того, насколько тяжёлая одна строка. Карточка с картинкой и кнопками стоит дороже строки текста, для неё overscan держат ниже.
| Подход | Когда работает | Цена |
|---|---|---|
| Фиксированная высота | Все строки одинаковы: таблицы, списки одного типа | Почти нет: чистая арифметика offset'ов |
| Оценка + измерение | Высота варьируется: карточки, комментарии, письма | Layout shift при скролле, ресайз-обсерверы на каждой видимой строке |
| Заранее известные высоты | Высоту можно вычислить из данных до рендера | Своя логика расчёта, но нет измерений в рантайме |
Где виртуализация ломается
Механизм красивый, но у него есть острые углы, о которые бьются в проде.
- Ctrl+F не находит текст. Строки вне окна не существуют в DOM, поэтому нативный поиск браузера по ним не работает. То же с доступностью: скринридер видит только отрендеренные узлы. Частично лечится атрибутами ARIA (
aria-setsize,aria-posinset), но нативный поиск не вернуть. - Сохранение позиции при обновлении данных. Если в список приходит новый элемент в начало, а вы не используете стабильные ключи, весь видимый диапазон перерисуется не туда. Ключ должен быть привязан к данным (
id), а не к индексу. - Горизонтальный скролл внутри строк. Абсолютное позиционирование и
overflowконфликтуют. Sticky-заголовки, вложенные скролл-области требуют отдельной возни. - Anchor при скролле вверх с динамической высотой. Самый болезненный случай — лента, куда догружаются старые сообщения сверху. Измерение подгруженных строк сдвигает всё содержимое, и без коррекции
scrollTopпользователя выкидывает из точки чтения. - Прыжки на трекпаде и мобильном инерционном скролле. Инерция генерирует события скролла с большими шагами, overscan не успевает, мелькает пустота. Здесь помогает рендер по
requestAnimationFrame, а не напрямую в обработчике.
Что и как мерить
Прежде чем внедрять виртуализацию, убедитесь, что проблема в количестве узлов, а не в том, что каждая строка тяжело рендерится. Виртуализация не ускоряет один компонент — она уменьшает их число.
- Число DOM-узлов. DevTools → Performance monitor показывает счётчик
DOM Nodesв реальном времени. Скролл не должен его наращивать: если растёт — где-то течёт, узлы не удаляются. - Длительность кадра при скролле. Вкладка Performance, запись во время прокрутки. Ищите долгие таски и красные полосы Long Task; расчёт диапазона должен укладываться в бюджет кадра.
- Layout shift на динамической высоте. Визуально прокрутите вверх-вниз: если содержимое дёргается, оценка
estimateSizeдалека от реальной — приблизьте её к среднему. - Память. DevTools → Memory, heap snapshot до и после долгого скролла. Detached-узлы означают утечку обработчиков.
Что делать дальше
Если список статичен по высоте — берите фиксированный режим и не усложняйте: это самый дешёвый и предсказуемый вариант. Для переменной высоты закладывайте бюджет на отладку скролл-якоря и подбирайте estimateSize по реальным данным, а не по красивому числу. Следующий шаг после базовой виртуализации — виртуализация в двух измерениях (большие таблицы с сотнями колонок) и предзагрузка данных по мере приближения к концу списка. Инвалидация кеша высот при смене данных — отдельная тема, здесь мы её не разворачиваем. Перед внедрением проверьте требования к доступности и поиску по странице: если Ctrl+F по списку критичен для пользователей, виртуализация может оказаться неприемлемым решением.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: TanStack Virtual — официальная документация, react-window — репозиторий и документация
Частые вопросы
У меня react-window, а не TanStack Virtual — примеры подойдут?
Скролл всё равно дёргается после виртуализации — что не так?
Почему при скролле вверх содержимое прыгает?
Виртуализация ломает Ctrl+F и скринридеры — это лечится?
С какого размера списка виртуализация оправдана?
Работает ли виртуализация с бесконечной прокруткой и догрузкой данных?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.