Виртуализация длинных списков: что ломается первым под нагрузкой
Список на 50 тысяч строк с виртуализацией дал фризы прокрутки по 300 мс на слабом ноутбуке. Разбираем, где именно теряется кадр: измерение высот, реюз DOM-узлов, реакция на быстрый скролл.

Таблица с историей операций на 50 000 строк, виртуализация через react-window, всё по документации. На рабочей машине разработчика — гладко. На ноутбуке пользователя с интегрированной графикой прокрутка дёргается, DevTools показывает Long Tasks по 200–300 мс во время быстрого скролла, а при resize окна на секунду появляется белый экран. Виртуализация не спасла — она сдвинула проблему в другое место. Разберём, где рвётся первым и почему.
Речь про клиентскую виртуализацию (windowing): в DOM держим не все элементы, а только видимые в окне прокрутки плюс небольшой буфер. Библиотеки — react-window, @tanstack/virtual (TanStack Virtual v3), react-virtuoso. Углы у них разные, но узкие места общие. Ниже — про поведение под нагрузкой, а не про то, как подключить библиотеку.
Что именно ломается первым
Порядок примерно такой — от самого частого к более редкому:
- Измерение динамической высоты строк. Как только высота элемента неизвестна заранее, библиотека вынуждена измерять реальный DOM. Это форсирует layout (reflow) на каждый неизмеренный элемент.
- Тяжёлый рендер одной строки. При быстром скролле создаётся и уничтожается десятки строк в кадр. Если строка — это дерево из 40 узлов с иконками и форматированием, GC и создание DOM съедают кадр целиком.
- Отсутствие overscan или его избыток. Слишком маленький буфер — белые полосы при быстром скролле. Слишком большой — рендерим лишнее, тот же фриз.
- Реакция на resize. Пересчёт всех позиций при изменении размеров контейнера, если кеш высот сбрасывается целиком.
Почему динамическая высота — главный источник фризов
Виртуализация с фиксированной высотой строки почти бесплатна: позиция элемента — это index * itemHeight, чистая арифметика, никакого обращения к DOM. Как только высота переменная (перенос текста, раскрывающиеся блоки), библиотека должна узнать реальную высоту. Пока элемент не отрендерен и не измерен, она либо использует оценку (estimate), либо измеряет по факту через getBoundingClientRect() или ResizeObserver.
Проблема в том, что чтение геометрии сразу после записи в DOM заставляет браузер синхронно посчитать layout — это forced reflow1. Один вызов дёшев. Тысяча вызовов в цикле во время скролла — это заметный джанк. В Chrome DevTools такой участок в Performance-профиле подсвечивается фиолетовым (Layout) с предупреждением про forced synchronous layout.
Виртуализация не устраняет стоимость рендера — она откладывает её на момент прокрутки. Если строка дорогая, вы просто перенесли фриз с загрузки страницы на скролл, где он заметнее.
Как измерять, а не гадать
Прежде чем оптимизировать, зафиксируйте условия. Профиль на машине разработчика бесполезен — там нет проблемы. Что делать:
- Chrome DevTools → Performance, включите CPU throttling ×4 или ×6. Это грубо имитирует слабый ноутбук.
- Запишите профиль во время быстрого скролла (перетаскивание ползунка), а не медленного. Джанк проявляется на скорости.
- Смотрите на Long Tasks (жёлтые блоки > 50 мс) и на фиолетовые участки Layout. Цель — ни один кадр не должен превышать бюджет ~16 мс при 60 fps.
- Отдельно проверьте вкладку Frames: пропущенные кадры при скролле видны как красные засечки.
Цифры до/после приводить бессмысленно без вашего железа и данных — форма и размер строки решают всё. Меряйте на своём наборе: 50k коротких строк и 50k строк с картинками ведут себя по-разному.
Четыре подхода к динамической высоте
Когда фиксировать высоту нельзя, есть несколько стратегий. У каждой своя цена.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Фиксированная высота, обрезка текста по overflow: hidden | Нулевая стоимость позиционирования, нет reflow | Контент режется; не подходит, если высота реально разная |
| Оценка высоты (estimateSize) + измерение по факту | Ползунок сразу правильного размера, скролл плавный при точной оценке | Прыжки полосы прокрутки, если оценка сильно расходится с реальностью |
Измерение через ResizeObserver с кешем высот | Точные высоты, реагирует на изменение контента | Reflow при первом появлении строки; нужен аккуратный кеш по ключу элемента, а не по индексу |
| Предрасчёт высот на сервере/при загрузке данных | Позиционирование без обращения к DOM вообще | Нужны стабильные шрифты и ширины; хрупко при разных вьюпортах |
На практике для таблиц с предсказуемым контентом выигрывает фиксированная высота. Для лент с текстом переменной длины — оценка плюс измерение, но с кешем высот, привязанным к стабильному ключу элемента данных, а не к индексу. Индекс меняется при вставке в начало списка, и весь кеш становится невалидным.
Пример: кеш высот по ключу (TanStack Virtual v3, React)
TanStack Virtual измеряет элементы через колбэк measureElement. Важно, чтобы ключ виртуального элемента был стабильным идентификатором строки, иначе после сортировки или вставки высоты уедут.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
// TanStack Virtual v3, React 18
function OperationsList({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
// оценка нужна, чтобы ползунок был правдоподобного размера до измерения
estimateSize: () => 56,
// ключ по id записи, а не по индексу: переживает сортировку и вставку
getItemKey: (index) => rows[index].id,
overscan: 8,
});
return (
{virtualizer.getVirtualItems().map((item) => (
))}
);
}
Ключевой момент — getItemKey. Без него после вставки записи в начало кеш высот сдвинется, и вы получите скачок скролла. Точное имя опций сверяйте с документацией вашей минорной версии: API TanStack Virtual менялось между бетами v3.
Тяжёлая строка: где уходит кадр
Даже с идеальным позиционированием фриз даёт сам рендер строки. При быстром скролле React монтирует и размонтирует десятки компонентов в кадр. Что съедает время:
- Инлайновые SVG-иконки. Каждая — это несколько DOM-узлов. Двадцать иконок на строку × десятки строк в кадр = тысячи узлов. Замените на спрайт (
<use>) или на шрифтовые/CSS-иконки для плотных списков. - Форматирование в рендере.
new Intl.NumberFormat(...)внутри рендера строки создаёт форматтер каждый раз. Выносите инстанс наружу и переиспользуйте. - Тени, фильтры,
backdrop-filter. Дорогая композиция на каждый видимый элемент. На слабой графике это первое, что роняет fps при скролле. - Отсутствие мемоизации строки. Если родитель ререндерится по любой причине, все видимые строки тоже — оберните строку в
React.memoс корректным сравнением пропсов.
Быстрый скролл и белые полосы
При резком перетаскивании ползунка браузер прокручивает быстрее, чем успевает отрендериться содержимое. Появляются пустые области. Здесь помогает overscan (буфер строк за пределами окна), но увеличивать его бесконечно нельзя — каждая лишняя строка это лишний рендер. Практический ориентир — держать overscan так, чтобы буфер покрывал примерно одну высоту вьюпорта, и мерить fps при быстром скролле.
Альтернатива для очень тяжёлых строк — рендерить упрощённый плейсхолдер во время активного скролла и подменять на полную строку, когда скролл замер (через debounce на событии). Virtuoso умеет это из коробки; в react-window придётся собирать вручную.
Граничные случаи, о которых забывают
- Resize окна. Если при изменении ширины строки меняют высоту (перенос текста), кеш высот надо инвалидировать — но не сбрасывать позицию скролла. Проверьте, что после resize вы остаётесь примерно на том же элементе, а не улетаете в начало.
- Прокрутка к элементу (scrollToIndex). При динамической высоте библиотека не знает точную позицию неизмеренного элемента и промахивается. После скролла нужен доскролл по факту измерения. Проверяйте на элементе в самом низу списка.
- Sticky-заголовки и группировка. Группы с залипающими шапками ломают простую арифметику позиций. Не все библиотеки это поддерживают нативно.
- Горизонтальный скролл вместе с вертикальным. Широкая таблица с виртуализацией только по строкам всё равно держит все колонки в DOM. При 60 колонках нужна виртуализация и по горизонтали.
- Доступность и поиск по странице. Ctrl+F не найдёт текст в неотрендеренных строках. Для таблиц с требованием поиска это принципиальное ограничение клиентской виртуализации.
Когда виртуализация не тот инструмент
Если список — 200 строк, виртуализация добавляет сложность и баги (сломанный Ctrl+F, промахи scrollTo) без выигрыша. Порог зависит от тяжести строки, но ориентир — думать про windowing начиная с нескольких тысяч узлов в DOM. Ниже часто выгоднее просто уменьшить вес строки. Если данных десятки тысяч и они приходят с сервера — рассмотрите серверную пагинацию или бесконечную подгрузку вместо того, чтобы тащить весь массив в браузер.
Что делать дальше
- Снимите Performance-профиль с CPU throttling ×4 при быстром скролле — до любых правок. Найдите, что именно фиолетовое (Layout) или жёлтое (Long Task).
- Сначала облегчите строку: спрайты вместо инлайновых SVG, вынесенные форматтеры,
React.memo. Часто этого достаточно без смены библиотеки. - Переведите кеш высот на стабильный ключ элемента, если список меняется в рантайме.
- Мерьте fps при быстром скролле после каждой правки, а не на глаз. Целевой бюджет кадра — 16 мс.
- Если строк десятки тысяч и они приходят с бэкенда — оцените серверную пагинацию как альтернативу.
1 Forced reflow (forced synchronous layout) — ситуация, когда JavaScript читает геометрию элемента (высоту, позицию) сразу после изменения DOM, и браузер вынужден синхронно пересчитать раскладку, не дожидаясь следующего кадра. Частые вызовы в цикле — типичная причина джанка.
Локализатор видео: Перевод и озвучка видео на другой язык с укладкой в исходный тайминг. Глоссарий терминов, отметки проблемных реплик.
Спросить за 250 ₽Источники: TanStack Virtual — официальная документация, web.dev: Avoid large, complex layouts and layout thrashing
Частые вопросы
У меня другая библиотека — react-window, а не TanStack. Приёмы подойдут?
Я всё сделал по гайду, но скролл всё равно дёргается. Что дальше?
Стоит ли вообще виртуализировать список на 500 строк?
Почему при вставке строки в начало списка скролл прыгает?
Виртуализация ломает поиск браузера по странице (Ctrl+F). Как быть?
Как виртуализация ведёт себя при resize окна в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.