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

Список из 50 000 строк, отрисованный целиком, — это 50 000 DOM-узлов, которые браузер держит в памяти, пересчитывает при каждом ресайзе и перекрашивает при скролле. На среднем ноутбуке начальная отрисовка такого списка занимает секунды, а фрейм-рейт при скролле падает до 10–15 кадров вместо 60. Задача виртуализации — рендерить только то, что попадает в видимую область плюс небольшой запас сверху и снизу, а остальное держать как пустое пространство нужной высоты.
Очевидное решение — навесить обработчик на scroll и вручную вычислять, какие элементы показать, — работает, но именно на нём чаще всего спотыкаются: пересчёт на каждый пиксель скролла сам становится узким местом. Разберём три рабочих подхода и цену каждого.
Почему полный список тормозит
Проблема не в количестве данных, а в количестве узлов. Браузер обрабатывает DOM в несколько этапов: пересчёт стилей, компоновка (layout), отрисовка, композитинг. Каждый добавленный элемент увеличивает стоимость layout и recalculate style — эти этапы линейны по числу узлов и блокируют главный поток.
При виртуализации в DOM живёт, скажем, 30–50 узлов независимо от того, 5 000 строк в данных или 500 000. Скролл создаёт иллюзию длинного списка за счёт контейнера-распорки заданной высоты и сдвига видимого окна.
Два режима: фиксированная и переменная высота строки
Всё усложняется, когда строки разной высоты. При фиксированной высоте позиция любого элемента считается умножением: index * rowHeight. При переменной высоту каждой строки нужно либо знать заранее, либо измерять после рендера и кешировать — а до измерения приходится показывать оценочную высоту, из-за чего скролл-бар «прыгает». Это ключевая развилка, от неё зависит выбор подхода.
Подход 1. CSS content-visibility — почти без JavaScript
Свойство content-visibility: auto говорит браузеру: не рендери содержимое элемента, пока он за пределами вьюпорта. Браузер сам пропускает layout и paint для скрытых блоков. Поддерживается в Chromium-браузерах и в Safari 18+; в Firefox на момент написания поддержки нет1, проверяйте актуальный статус в caniuse.
/* Каждая строка списка */
.list-row {
content-visibility: auto;
/* оценка размера, чтобы скроллбар не прыгал */
contain-intrinsic-size: auto 48px;
}contain-intrinsic-size здесь обязателен: без него браузер считает высоту скрытого элемента нулевой, скроллбар схлопывается, а при появлении строки в кадре высота резко меняется. Значение auto 48px означает «используй 48px как оценку, но запомни реальный размер после первого рендера».
Главное достоинство подхода — вы не пишете логику виртуализации вообще. DOM-узлы физически остаются в дереве, но браузер не тратит на скрытые ресурсы. Главный минус — узлы всё равно в DOM, поэтому на списках в сотни тысяч строк начальный парсинг разметки останется дорогим.
Подход 2. Ручная виртуализация окна
Классическая схема: контейнер с фиксированной высотой и overflow: auto, внутри — распорка полной высоты списка, внутри неё — только видимые строки, смещённые через transform: translateY.
const rowHeight = 48; // высота одной строки, px
const overscan = 5; // запас строк сверху и снизу
function getVisibleRange(scrollTop, viewportHeight, totalRows) {
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2;
const end = Math.min(totalRows, start + visibleCount);
return { start, end };
}
// offsetY для сдвига видимого блока внутри распорки
const offsetY = start * rowHeight;Обработчик скролла не должен пересчитывать окно синхронно на каждое событие. Скролл-события летят десятками в секунду, а вычисление и обновление DOM в каждом из них съедает бюджет кадра. Оборачивайте пересчёт в requestAnimationFrame и пропускайте лишние вызовы:
let ticking = false;
container.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const range = getVisibleRange(
container.scrollTop,
container.clientHeight,
data.length
);
renderRows(range.start, range.end); // ваша функция отрисовки окна
ticking = false;
});
});Смещать видимый блок лучше через transform: translateY(offsetY + 'px'), а не через top или margin: transform обрабатывается на этапе композитинга и не вызывает повторный layout.
Переменная высота ломает всю арифметику: index * rowHeight перестаёт работать. Тогда нужен массив накопленных смещений (префикс-суммы высот) и бинарный поиск по нему, чтобы по scrollTop найти первую видимую строку. Высоты неизмеренных строк оцениваются, а после рендера — уточняются. Это заметный объём кода, и здесь разумнее взять готовую библиотеку.
Подход 3. Готовые библиотеки
Если строки переменной высоты, есть вложенные списки или горизонтальная виртуализация, самописное решение быстро обрастает крайними случаями. Проверенные варианты для React-экосистемы — TanStack Virtual (бывший react-virtual) и react-window. Для Vue есть vue-virtual-scroller. Все они дают хуки/компоненты, которым вы передаёте число элементов и функцию высоты, а рендер окна и обработку скролла берут на себя.
TanStack Virtual (версия 3.x) не привязан к React жёстко — ядро фреймворк-независимо, что удобно, если проект не на React. Точные имена хуков и опций смотрите в документации вашей версии, они менялись между 2.x и 3.x.
Что выбрать
| Критерий | content-visibility | Ручная виртуализация | Библиотека |
|---|---|---|---|
| Объём кода | Минимальный | Средний, растёт с крайними случаями | Малый |
| Строки переменной высоты | Работает из коробки | Сложно, нужен бинарный поиск | Работает, поддерживается |
| Список 100 000+ строк | Парсинг DOM всё ещё дорог | Отлично, узлов мало | Отлично |
| Поддержка Firefox | Нет (на момент написания) | Да | Да |
| Внешняя зависимость | Нет | Нет | Есть |
Ориентир по выбору:
- content-visibility — список до нескольких тысяч строк, аудитория на Chromium/Safari, хочется минимума кода;
- ручная виртуализация — строки фиксированной высоты, десятки тысяч строк, не хочется тянуть зависимость;
- библиотека — переменная высота, вложенность, горизонтальный скролл или список за сотню тысяч строк.
Что ломается на практике
- Скачущий скроллбар при переменной высоте — самая частая жалоба. Причина: оценочная высота сильно расходится с реальной. Лечится кешированием измеренных высот и разумной начальной оценкой.
- Поиск по странице (Ctrl+F) не находит текст в невиртуализированных строках, потому что их нет в DOM. С
content-visibilityтекст остаётся доступен для поиска — это её преимущество. - Потеря фокуса и состояния в полях ввода внутри строк: когда строка уходит из окна и узел переиспользуется под другую строку, фокус слетает. Привязывайте состояние к данным, а не к DOM-узлу.
- Прыжок при быстром скролле — если рендер окна не успевает за скроллом, появляется белая область. Увеличьте
overscanи убедитесь, что пересчёт идёт вrequestAnimationFrame.
Что делать дальше
Начните с замера: откройте DevTools, вкладку Performance, запишите скролл по полному списку и посмотрите на длительность Recalculate Style и Layout. Если суммарно они съедают больше 8–10 мс на кадр, виртуализация оправдана. Для быстрой проверки гипотезы наклейте content-visibility: auto — это одна строка CSS и мгновенный результат. Если этого мало или нужен Firefox, переходите к ручному окну или библиотеке. Отдельная тема — виртуализация таблиц с фиксированной шапкой и горизонтальным скроллом: там добавляются свои крайние случаи, здесь мы их не разбирали.
1 content-visibility — CSS-свойство, позволяющее браузеру пропускать рендеринг содержимого элемента, пока оно за пределами экрана; часть спецификации CSS Containment.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: content-visibility — MDN Web Docs, TanStack Virtual — официальная документация
Частые вопросы
А если у меня строки разной высоты — какой подход брать?
Работает ли content-visibility в Firefox?
Виртуализация сломает поиск по странице через Ctrl+F?
Почему нельзя просто обновлять окно прямо в обработчике scroll?
Что делать, если при быстром скролле появляется белая пустая область?
Есть ли смысл виртуализировать список из пары сотен строк?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.