Олег Марков
Virtual Scrolling - как эффективно отображать большие списки
Введение
Virtual Scrolling (виртуальный скроллинг, также называют windowing) — это прием, который позволяет отрисовывать на экране только те элементы длинного списка, которые реально видит пользователь, а остальное «симулировать» с помощью пустого пространства и вычислений.
Если вы когда‑нибудь пробовали вывести в DOM десятки тысяч элементов, вы уже знаете, что браузер начинает заметно тормозить: рендеринг, перерасчет стилей, обработка событий становятся тяжелыми. Виртуальный скроллинг решает эту проблему, уменьшая количество реальных DOM‑элементов до разумного минимума, но при этом визуально создавая иллюзию полного списка.
Давайте разберем, как это работает изнутри, какие есть ключевые идеи и паттерны реализации, а затем посмотрим на практические примеры — от «ванильного» JavaScript до подключения готовых решений во фреймворках.
Что такое Virtual Scrolling и зачем он нужен
Основная идея
Смотрите, логика простая:
- у вас есть большой список данных (например, 100 000 строк);
- пользователь в каждый момент времени видит только небольшое окно — скажем, 20–40 элементов;
- вместо того чтобы создавать 100 000 DOM‑узлов, вы создаете только те 20–40 (плюс небольшой запас), которые попадают в видимую область;
- при прокрутке вы не добавляете новые элементы, а переиспользуете существующие, просто меняя их содержимое и позицию.
Визуально человек видит полный список, но в DOM присутствует лишь небольшое «окно» элементов. Отсюда альтернативное название — windowing.
Какие проблемы решает Virtual Scrolling
Производительность рендера
- Меньше элементов в DOM — быстрее layout, paint и JavaScript‑операции.
- Снижается нагрузка на GC, потому что создается меньше объектов.
Память
- Вы не держите в DOM тысячи тяжелых узлов (сложные ячейки таблиц с кучей вложенных элементов и обработчиков).
Интерактивность
- Интерфейс не «подвисает» при открытии страниц с большими списками или таблицами.
- Скролл остается плавным даже при десятках тысяч строк.
Когда виртуальный скроллинг действительно нужен
Он особенно полезен, когда:
- у вас есть длинные списки: лог событий, чат, фиды, таблицы отчетов;
- элементы списка визуально однородны (или хотя бы похожи по высоте);
- нужно поддерживать мгновенную реакцию UI при прокрутке.
Если список небольшой (до нескольких сотен элементов), а сами элементы простые, то иногда проще обойтись без виртуализации, чтобы не усложнять код.
Как устроен виртуальный скроллинг изнутри
Ключевые компоненты реализации
Любая реализация Virtual Scrolling обычно включает:
Контейнер прокрутки
Элемент сoverflow: autoили самwindow, у которого мы слушаем событиеscroll.Виртуальный размер контента
Внутри контейнера создается большой блок, который имитирует полную высоту списка. Он нужен, чтобы у ползунка прокрутки была правильная длина.Окно видимых элементов
Небольшой список реально отрисованных элементов, которые накладываются поверх виртуального блока с помощью отступов (margin, transform, top и т. д.).Алгоритм расчета индексов
Логика, которая по текущемуscrollTopвычисляет:- индекс первого видимого элемента;
- индекс последнего видимого;
- сколько элементов добавить как буфер сверху и снизу.
Базовая математика виртуального скроллинга
Предположим, у вас:
itemCount— общее число элементов;itemHeight— высота одного элемента (фиксированная для простоты);viewportHeight— высота окна просмотра (контейнера);scrollTop— текущая позиция прокрутки;overscan— количество элементов запаса сверху и снизу (например, 5–10).
Тогда:
- общая высота списка:
totalHeight = itemCount * itemHeight - индекс первого элемента, который попадает в окно:
startIndex = Math.floor(scrollTop / itemHeight) - сколько элементов помещается во вьюпорт:
visibleCount = Math.ceil(viewportHeight / itemHeight) - индекс последнего видимого:
endIndex = Math.min(itemCount - 1, startIndex + visibleCount - 1)
С учетом буфера:
renderStart = Math.max(0, startIndex - overscan)renderEnd = Math.min(itemCount - 1, endIndex + overscan)
Теперь вы показываете только элементы с индексами от renderStart до renderEnd.
Пример простой реализации на чистом JavaScript
Сейчас я покажу вам минимальную работающую схему, чтобы стало понятно, как это выглядит на практике.
HTML и стили
<div id="viewport">
<div id="spacer"></div> <!-- этот блок имитирует общую высоту списка -->
<div id="items"></div> <!-- сюда будут рендериться видимые элементы -->
</div>
#viewport {
height: 400px; /* высота окна просмотра */
overflow-y: auto; /* вертикальный скролл */
position: relative; /* чтобы позиционировать элементы внутри */
border: 1px solid #ccc;
box-sizing: border-box;
}
#spacer {
width: 1px; /* неважно, лишь бы блок занимал место по высоте */
}
#items {
position: absolute; /* будем двигать всю группу элементов вниз */
top: 0;
left: 0;
right: 0;
}
Комментарии:
#viewport— контейнер с прокруткой.#spacer— пустой блок, которому мы зададимheight: totalHeight, чтобы у скролла была правильная «длина».#items— над ним мы будем рисовать реальный набор элементов, сдвигая его вниз черезtransformилиtop.
JavaScript‑логика
Давайте разберемся на примере с 100 000 элементов фиксированной высоты.
const viewport = document.getElementById('viewport');
const spacer = document.getElementById('spacer');
const itemsEl = document.getElementById('items');
// Параметры списка
const itemCount = 100000; // всего элементов
const itemHeight = 30; // высота одного элемента в пикселях
const overscan = 5; // запас элементов сверху и снизу
// Массив данных
const items = Array.from({ length: itemCount }, (_, i) => `Элемент № ${i}`);
// Устанавливаем общую высоту списка
spacer.style.height = `${itemCount * itemHeight}px`;
// Функция рендеринга видимого окна
function render() {
const viewportHeight = viewport.clientHeight; // высота видимой области
const scrollTop = viewport.scrollTop; // текущая прокрутка
// Вычисляем первый видимый элемент
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const endIndex = Math.min(
itemCount - 1,
startIndex + visibleCount - 1
);
// Добавляем буфер (overscan)
const renderStart = Math.max(0, startIndex - overscan);
const renderEnd = Math.min(itemCount - 1, endIndex + overscan);
const fragment = document.createDocumentFragment();
// Очищаем контейнер с элементами
itemsEl.innerHTML = '';
// Создаем только те элементы, которые попадают в окно
for (let i = renderStart; i <= renderEnd; i++) {
const div = document.createElement('div');
div.textContent = items[i];
div.style.height = `${itemHeight}px`;
div.style.boxSizing = 'border-box';
div.style.borderBottom = '1px solid #eee';
// Можно добавить любые обработчики событий
// div.addEventListener('click', () => console.log('Click', i));
fragment.appendChild(div);
}
itemsEl.appendChild(fragment);
// Сдвигаем группу элементов вниз, чтобы они совпали с позицией в списке
const offsetY = renderStart * itemHeight;
itemsEl.style.transform = `translateY(${offsetY}px)`;
}
// Привязываем рендер к событию scroll
viewport.addEventListener('scroll', () => {
// Здесь можно добавить throttling или requestAnimationFrame
render();
});
// Первый рендер
render();
Что здесь важно:
- мы всегда создаем относительно маленькое количество DOM‑элементов;
- при прокрутке мы пересоздаем этот набор (в простом варианте) или, в более оптимизированной версии, переиспользуем уже созданные элементы;
- пользователю кажется, что он скроллит огромный список, хотя на самом деле видит только его фрагмент.
Оптимизация: переиспользование DOM‑элементов
В предыдущем примере при каждом скролле мы очищали контейнер и заново создавали дочерние элементы. Это проще, но не идеально по производительности, если прокрутка очень активная.
Идея переиспользования
Вы можете создать фиксированное число DOM‑элементов (например, visibleCount + 2 * overscan) и просто обновлять их содержимое и позицию:
- количество элементов в DOM не меняется;
- на скролл вы обновляете только текст и, при необходимости, связанные данные.
Схема такая:
- При инициализации вычисляете максимальное число элементов, которое когда‑либо понадобится отрисовать за раз.
- Создаете столько
div(илиli) и кладете их вitemsEl. - В обработчике
scroll:- считаете
renderStart; - для каждого DOM‑элемента считаете, какой индекс списка он сейчас должен представлять;
- обновляете текст/данные.
- считаете
Это особенно полезно, если элементы сложные: таблицы, карточки с картинками, сложные иконки.
Варианты реализации: фиксированная и динамическая высота элементов
Фиксированная высота (проще всего)
Вы уже увидели пример: если каждый элемент имеет одинаковую высоту, математика и код становятся заметно проще:
- легко считать
startIndexкакscrollTop / itemHeight; - легко вычислить
totalHeightкакitemCount * itemHeight; - не нужно хранить сложные структуры для высот элементов.
Если есть возможность зафиксировать высоту строк (например, для таблиц с текстом без переноса), это сильно снижает сложность реализации.
Динамическая высота (сложнее, но реальнее)
В реальных приложениях часто встречаются:
- строки, которые занимают разное количество строк текста;
- карточки с переменной высотой описания;
- блоки с картинками и т. д.
С динамической высотой элементы могут отличаться по размеру, а значит, формула scrollTop / itemHeight уже не работает. Здесь есть несколько подходов.
Подход 1. Предварительные измерения
Если у вас есть возможность заранее узнать высоты (например, сервер отдает размер каждой строки), вы можете:
- хранить массив высот
heights[i]; - посчитать массив кумулятивных сумм
offsets[i], гдеoffsets[i]— вертикальное смещение начала элементаiот верха списка; - использовать бинарный поиск по
offsets, чтобы поscrollTopнайти соответствующийstartIndex.
Но на практике заранее знать высоты сложно.
Подход 2. Мгновенное измерение в браузере
Чаще используют стратегию:
- Изначально предполагают какую‑то среднюю высоту (например, 40 пикселей).
- Рендерят элементы.
- Измеряют фактическую высоту через
getBoundingClientRect(). - Обновляют хранимые размеры в структуре данных и пересчитывают смещения.
Смотрите, псевдокод может выглядеть так:
// Массив высот, поначалу у всех одинаковое предположение
const heights = new Array(itemCount).fill(40);
// Массив кумулятивных смещений
const offsets = new Array(itemCount + 1).fill(0);
// Пересчитываем offsets на основе heights
function recomputeOffsets() {
offsets[0] = 0;
for (let i = 0; i < itemCount; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
}
// Функция бинарного поиска индекса по scrollTop
function findStartIndex(scrollTop) {
let low = 0;
let high = itemCount;
while (low < high) {
const mid = Math.floor((low + high) / 2);
// Если смещение следующего элемента все еще выше scrollTop - двигаем low
if (offsets[mid + 1] <= scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
return low; // индекс первого элемента, начало которого не ниже scrollTop
}
Дальше:
- после рендера очередного окна вы проходите по реально созданным DOM‑элементам;
- измеряете их высоту;
- обновляете
heights[i]и пересчитываетеoffsets.
Такой подход часто используется в продвинутых библиотеках виртуального скроллинга.
Обработка событий и взаимодействий
Клики, ховер, контекстное меню
Виртуализация никак не запрещает обрабатывать события, но есть нюанс: элементы постоянно переиспользуются. Это значит:
- обработчики лучше вешать не на конкретный элемент списка, а через делегирование;
- данные, связанные с элементом, нужно получать по индексу или id.
Пример делегирования в нашем базовом случае:
// Здесь мы обрабатываем клики на родительском контейнере
itemsEl.addEventListener('click', (event) => {
const target = event.target;
// Предположим, мы записали индекс как data-атрибут
const indexAttr = target.getAttribute('data-index');
if (indexAttr == null) return;
const index = Number(indexAttr);
// Здесь мы можем получить реальные данные
const item = items[index];
// Обрабатываем клик по элементу с индексом index
console.log('Клик по элементу', index, item);
});
Соответственно, при рендере элемента не забывайте проставить data-index:
div.setAttribute('data-index', i); // здесь i — индекс элемента
Комментарии:
- делегирование на контейнере позволяет вам не переинициализировать обработчики при каждом скролле;
- это особенно важно, когда количество видимых элементов все равно большое (например, 100–200).
Фокус и клавиатурная навигация
Если вы поддерживаете навигацию по стрелкам, PgUp/PgDn и т. п., важно:
- уметь программно прокручивать к нужному индексу;
- уметь корректно фокусировать виртуальный элемент, который может быть еще не отрисован.
Типичный подход:
- У вас есть функция
scrollToIndex(index). - При нажатии стрелки вы считаете, какой будет следующий индекс.
- Вызываете
scrollToIndexи после рендера находите DOM‑элемент, который представляет этот индекс, и ставите на негоfocus().
Функции и методы, полезные при реализации Virtual Scrolling
Здесь соберу ключевые операции, которые вам почти наверняка понадобятся.
Функция вычисления видимого диапазона
function getVisibleRange({
scrollTop,
viewportHeight,
itemCount,
itemHeight,
overscan = 5,
}) {
// Индекс первого видимого элемента
const startIndex = Math.floor(scrollTop / itemHeight);
// Сколько элементов помещается в окно
const visibleCount = Math.ceil(viewportHeight / itemHeight);
// Индекс последнего видимого
const endIndex = Math.min(
itemCount - 1,
startIndex + visibleCount - 1
);
// Диапазон с учетом буфера
const renderStart = Math.max(0, startIndex - overscan);
const renderEnd = Math.min(itemCount - 1, endIndex + overscan);
return {
startIndex,
endIndex,
renderStart,
renderEnd,
};
}
Комментарии:
- эта функция удобна, если вы хотите отделить «математику» от кода рендера;
- вы можете переиспользовать ее и в чистом JS, и в React/Vue/Angular.
Функция scrollToIndex для фиксированной высоты
function scrollToIndex(viewport, index, itemHeight) {
// Здесь viewport - это DOM элемент с overflow-y auto
// index - номер элемента, к которому нужно проскроллить
// itemHeight - высота одного элемента
const targetScrollTop = index * itemHeight;
viewport.scrollTop = targetScrollTop;
}
Для динамической высоты вместо index * itemHeight нужно использовать предвычисленные смещения offsets[index].
Ограничение частоты перерендеринга (throttling / rAF)
Если вы напрямую вешаете рендер на событие scroll, он может вызываться очень часто (десятки раз в секунду). Чтобы снизить нагрузку, часто используют requestAnimationFrame:
let ticking = false;
// Здесь мы обрабатываем scroll с помощью requestAnimationFrame
viewport.addEventListener('scroll', () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
render(); // вызываем отрисовку элементов
ticking = false;
});
}
});
Комментарии:
requestAnimationFrameгарантирует, что рендер будет выполняться не чаще, чем частота обновления экрана;- это помогает избежать «дребезга» и лишних перерасчетов при быстрой прокрутке.
Virtual Scrolling в популярных фреймворках
Чтобы вам было проще применять знания на практике, давайте посмотрим, как это реализуется в распространенных фреймворках.
React: react-window и react-virtualized
В React‑экосистеме наиболее популярны:
react-window— более легкая, современная библиотека;react-virtualized— более старая, но с большим количеством виджетов (таблицы, списки, гриды).
Пример с react-window:
import { FixedSizeList as List } from 'react-window';
// Здесь мы создаем компонент строки списка
function Row({ index, style }) {
// index - индекс элемента в массиве
// style - стили для позиционирования элемента (обязательно нужно применить)
return (
<div style={style}>
{/* Здесь мы можем рендерить любые данные по индексу */}
Элемент № {index}
</div>
);
}
// Используем компонент List для виртуального списка
export function MyVirtualList() {
const itemCount = 100000; // количество элементов
const itemHeight = 35; // высота одного элемента
return (
<List
height={400} // высота вьюпорта
itemCount={itemCount} // количество элементов
itemSize={itemHeight} // фиксированная высота строки
width={300} // ширина списка
>
{Row}
</List>
);
}
Комментарии:
react-windowсам считает индексы, обертки, виртуальную высоту;- вам нужно просто описать, как выглядит одна строка, и передать ее в компонент
List; - под капотом используется та же идея окна видимости и переиспользования DOM‑элементов.
Angular: Angular CDK Virtual Scroll
В Angular есть встроенный модуль @angular/cdk/scrolling, который предоставляет директиву cdk-virtual-scroll-viewport.
Пример:
<cdk-virtual-scroll-viewport
itemSize="50"
class="viewport"
>
<div *cdkVirtualFor="let item of items">
{{ item }}
</div>
</cdk-virtual-scroll-viewport>
// Здесь мы подключаем модуль ScrollingModule в ваш Angular модуль
import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
imports: [
// другие модули
ScrollingModule,
],
})
export class AppModule {}
Комментарии:
itemSize— высота элемента;*cdkVirtualFor— аналог*ngFor, но с виртуализацией;- все вычисления берут на себя компоненты CDK, вам остается только описать шаблон элемента.
Vue: готовые компоненты
Для Vue существует несколько библиотек, например:
vue-virtual-scroller;vue3-virtual-scroller(для Vue 3).
Простейший пример с vue-virtual-scroller:
<template>
<!-- Здесь DynamicScroller отвечает за виртуальный список -->
<DynamicScroller
:items="items"
:min-item-size="30"
class="viewport"
>
<!-- Здесь мы описываем, как отображать каждый элемент -->
<template #default="{ item, index }">
<div class="row">
{{ index }} - {{ item }}
</div>
</template>
</DynamicScroller>
</template>
<script>
import { DynamicScroller } from 'vue-virtual-scroller';
export default {
components: { DynamicScroller },
data() {
return {
items: Array.from({ length: 100000 }, (_, i) => `Элемент № ${i}`),
};
},
};
</script>
Комментарии:
- компонент берет на себя весь расчет видимой области;
- вам достаточно передать массив
itemsи описать шаблон.
Типичные проблемы и подводные камни
1. «Прыжки» при динамической высоте элементов
Когда высота элементов меняется после рендера (например, подгружаются картинки), может казаться, что список «подпрыгивает».
Что можно сделать:
- задавать максимально возможную или приблизительную высоту до загрузки контента;
- подгружать тяжелый контент (например, изображения) с
loading="lazy"и фиксированными размерами; - использовать библиотеки, которые умеют переоценивать и кэшировать высоты (например,
react-virtualizedс измерителями).
2. Неожиданные эффекты при использовании position: sticky
Элементы с position: sticky внутри виртуального списка могут вести себя не так, как вы ожидаете: при переиспользовании DOM‑узлов поведение «прилипания» может быть странным.
По возможности:
- избегайте sticky внутри виртуализированного контейнера;
- выносите закрепленные заголовки наверх, поверх списка, и управляйте их состоянием отдельно.
3. SEO и индексация
Если вы используете виртуализацию в списках, которые важны для SEO (например, длинные каталоги на сайте), важно понимать:
- поисковые роботы могут не прокручивать страницу и не подгружать нижние части списка;
- часть контента может просто оказаться невидимой для индексации.
Выходы:
- для SEO‑критичных страниц использовать серверную отрисовку полного списка без виртуализации (или ограниченным количеством элементов);
- разделять каталоги на страницы (pagination), а не бесконечный скролл.
4. Сложность отладки
Виртуализация усложняет:
- отладку DOM — вы не видите весь список, только небольшое окно;
- работу с «найти элемент по селектору» в DevTools — нужного DOM‑узла может не быть в текущий момент.
Совет: добавляйте диагностический режим, в котором можно отключить виртуализацию, чтобы посмотреть весь список целиком (например, для внутренних окружений разработки).
Заключение
Virtual Scrolling — это не один конкретный инструмент, а общий прием организации длинных списков. Вы:
- создаете контейнер с прокруткой;
- симулируете полную высоту списка с помощью внутреннего блока;
- показываете только небольшой диапазон элементов, который попадает в видимую область;
- пересчитываете этот диапазон при скролле, переиспользуя DOM‑элементы.
При фиксированной высоте элементов реализация получается довольно прямолинейной: достаточно нескольких формул и аккуратного кода. С динамической высотой добавляются измерения, кумулятивные смещения и иногда сложная логика переоценки.
На практике чаще всего используют готовые библиотеки и компоненты: react-window, react-virtualized, Angular CDK Virtual Scroll, vue-virtual-scroller и другие. Но понимание внутренней идеи помогает:
- выбрать правильный инструмент;
- настроить его под свои задачи;
- отладить проблемы с высотой, скроллом, событиями и фокусом.
Если вы держите в голове ключевые понятия — окно видимости, виртуальная высота, индексы видимых элементов, overscan и переиспользование DOM — вам будет проще внедрять и поддерживать виртуальный скроллинг в реальных проектах.
Частозадаваемые технические вопросы по теме Virtual Scrolling
Как организовать поиск по списку с Virtual Scrolling если в DOM присутствуют только видимые элементы
Поиск нужно выполнять по данным, а не по DOM. Смотрите, алгоритм такой:
- Храните исходный массив данных отдельно от рендера.
- При вводе поискового запроса фильтруйте массив в памяти.
- Передавайте отфильтрованный массив в компонент виртуального списка вместо исходного.
- Сбрасывайте
scrollTopна 0 после смены набора данных. Так вы находите элементы по всему набору, независимо от того, виден он сейчас в DOM или нет.
Как корректно измерять высоту элементов при динамическом контенте
Действуйте по шагам:
- Рендерьте элементы с приблизительной высотой.
- После монтирования элемента используйте
element.getBoundingClientRect().heightдля фактического значения. - Сохраните измеренную высоту в массив
heights[index]. - Пересчитайте кумулятивные смещения
offsetsи перерисуйте окно. - Ограничьте частоту таких пересчетов с помощью
requestAnimationFrameили batched‑обновлений, чтобы не вызвать лавину reflow.
Что делать если при быстрой прокрутке список не успевает перерисовываться и появляются «дыры»
Здесь помогает оптимизация рендера:
- Оберните обработчик
scrollвrequestAnimationFrame, как в примере выше. - Увеличьте
overscan, чтобы в запасе было больше элементов за пределами экрана. - Уменьшите сложность шаблона элемента: уберите лишние вычисления и тяжелые компоненты.
- В фреймворках вроде React используйте мемоизацию и
React.memo, чтобы не перерендеривать лишние элементы.
Как реализовать сохранение позиции скролла при повторном открытии страницы с виртуальным списком
Схема такая:
- При каждом изменении
scrollTopсохраняйте значение вlocalStorageили состояние роутера. - При инициализации списка:
- прочитайте сохраненное значение;
- установите
viewport.scrollTop = savedScrollTop; - выполните рендер окна на основе этого значения.
- Если у вас динамическая высота, предварительно прогрейте кэш высот или используйте усредненное значение, а затем корректируйте позицию после измерений.
Как совместить виртуальный скроллинг и бесконечную подгрузку данных infinite scroll
Здесь часто делают так:
- Виртуальный список работает поверх массива данных, который может расти.
- В обработчике скролла отслеживайте, что пользователь приблизился к концу:
if (endIndex > items.length - threshold), гдеthreshold— запас. - При достижении порога отправляйте запрос на сервер за следующей порцией данных и добавляйте их в массив.
- Не забывайте перерасчитывать
itemCountи общую высоту списка после подгрузки.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев