Цена рендера и виртуализация: таблица на 10 000 строк, рендерящая 30
Объём рендера убивает: таблица на 10k строк — это 300k DOM-узлов и секунда на нажатие. Виртуализация рендерит только viewport плюс overscan (TanStack Virtual: estimateSize, measureElement, translate); content-visibility — CSS-родственник для статики. Ключи — id данных.
Команде бэк-офиса досталась таблица транзакций, которая «нормально работала на стейджинге». На стейджинге было 200 строк; у крупнейшего клиента — 11 400. На его аккаунте ввод в поле фильтра стоил 1,3 секунды на нажатие, а скролл заикался даже при нетронутом фильтре. Первая реакция была рефлекторной: React.memo на компонент строки, useMemo на форматтер. Время коммита упало с 1,3 с до 1,1 с — мемоизация немного помогла реактовской половине, но страница по-прежнему таскала 340 000 DOM-узлов, и каждая смена фильтра по-прежнему просила браузер пересчитать стили и лейаут документа высотой 90 000 пикселей. Настоящим фиксом стало признание: ни один пользователь никогда не видит 11 400 строк разом. Виртуализатор стал рендерить 25 строк во viewport плюс немного overscan; DOM упал с 340 000 узлов до полутора тысяч, коммиты на нажатие — до 18 мс, а скролл вернулся к нативной скорости. Те же данные, тот же компонент строки — на два порядка меньше работы, потому что эта работа никогда не была нужна.
Почему 10 000 строк медленны дважды
Большой список бьёт по вам в двух разных движках, а мемоизация разговаривает только с одним. Первый — цена React: апдейт, задевающий список, — нажатие в фильтре, сортировка, тик вебсокета — перерендеривает компоненты строк. Даже дешёвая строка за 0,1 мс, умноженная на 10 000, — целая секунда фазы рендера, а React.memo помогает лишь строкам с неизменившимися пропсами, что фильтр по определению ломает. Второй — цена браузера, существующая даже когда React простаивает: строка из восьми ячеек — это легко 30+ DOM-узлов, то есть 10k строк — порядка 300k узлов, которые едят память, замедляют пересчёт стилей и заставляют каждый проход лейаута обходить документ высотой в десятки тысяч пикселей. Поэтому мемоизация команды из истории срезала 0,2 с — и не больше: они оптимизировали движок React, пока тонул движок лейаута.
Структурное наблюдение, чинящее оба: viewport показывает от силы 25 строк. Всё остальное рендерится, верстается и рисуется ни для кого. Виртуализация (windowing) рендерит только видимый срез в контейнер с одним высоким распоркой-элементом, сохраняющим геометрию скроллбара, и передвигает срез по мере прокрутки. React рендерит 30 компонентов вместо 10 000; браузер владеет 1 500 узлами вместо 300 000. Список становится O(viewport) вместо O(данных).
TanStack Virtual: механика
Прежде чем брать конкретную библиотеку, стоит спросить: навязывает ли виртуализатор свою DOM-структуру или оставляет разметку вам? TanStack Virtual — headless (безголовый): всю математику видимых диапазонов берёт на себя, а HTML остаётся вашим — это важно, как только нужен нестандартный лейаут строки или особый скролл-контейнер.
Headless-виртуализатор вроде TanStack Virtual делает три вещи: следит за позицией скролла, решает, какие индексы видимы, и говорит, куда их абсолютно позиционировать. Разметка остаётся полностью вашей:
function TransactionList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // догадка в px на строку — исправляется замером
overscan: 5, // запасные строки выше/ниже viewport
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((v) => (
<div
key={items[v.index].id} // id данных — никогда v.index
ref={virtualizer.measureElement} // меряет реальную высоту
data-index={v.index}
style={{ position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${v.start}px)` }}
>
<Row item={items[v.index]} />
</div>
))}
</div>
</div>
);
}Подвижные части стоит назвать поимённо. estimateSize — догадка; при динамических высотах measureElement наблюдает каждую смонтированную строку и заменяет догадку реальностью, сдвигая офсеты всего, что ниже, — дико ошибочная оценка заставляет скроллбар прыгать по мере поступления замеров, так что оценивайте по реальной медианной строке. overscan рендерит несколько строк за пределами viewport, чтобы быстрый скролл показывал контент, а не белые вспышки; каждая единица overscan — оплаченный рендер, который могут никогда не увидеть, поэтому обычный диапазон 3–10, а не 50. Ожидаемые сбои: position: sticky для заголовков естественно не работает внутри списка, позиционированного transform-ом (собственный translate виртуализатора ломает containing block для sticky — выносите шапки за пределы скролл-контейнера или используйте поддержку sticky-элементов в самом виртуализаторе); а удалённым данным нужен count с сервера, прежде чем скроллбар сможет быть честным.
В виртуализированном списке разработчик ставит строкам key={v.index} (виртуальный индекс). Строки раскрываются по клику. Пользователь раскрыл строку 3, проскроллил 500 строк вниз — и раскрытой оказалась какая-то посторонняя строка. Почему?
content-visibility: альтернатива на CSS
Иногда виртуализировать нельзя — разметка приходит из CMS, список — семантический контент, где важны поиск по странице и Ctrl-F, или цена рефакторинга не окупается. CSS content-visibility: auto велит браузеру пропускать работу отрисовки (стили, лейаут, paint) для элементов вне экрана, оставляя их в DOM; в паре с contain-intrinsic-size: auto 44px пропущенные элементы резервируют оценочное место, и скроллбар остаётся вменяемым. Ключевое отличие от windowing: узлы продолжают существовать. React всё равно отрендерил все 10 000 компонентов, память всё ещё держит 300k узлов, а поиск по странице работает, потому что контент настоящий. То есть content-visibility спасает скролл и первичный лейаут (движок браузера), но ничего не делает с ценой апдейтов (движок React) — нажатие клавиши, перерендеривающее 10 000 строк, ровно так же медленно, как раньше. Правило выбора: браузерная цена на почти статичном контенте → content-visibility, одна строка CSS; реактовская цена апдейтов на интерактивных данных → виртуализация, настоящий рефакторинг. Таблице из истории нужен был рефакторинг, потому что медленным был ввод, а не только скролл.
Длинная статья (тысячи DOM-узлов, никакой интерактивности) плохо скроллится. Таблица на 10k строк с живыми обновлениями тормозит на каждом тике данных. Какой инструмент к какой проблеме?
- 01Таблица на 10k строк тормозит и при вводе в фильтр, и при скролле. Объясните два разных центра затрат и почему React.memo всерьёз не чинит ни один.
- 02Назовите три классические эксплуатационные ловушки оконного списка — высоты строк, overscan и ключи — и что ломается в каждой при наивном подходе.
Таблица на 10 000 строк медленна дважды, в двух разных движках. React платит O(n) за апдейт: нажатие в фильтре перерендеривает строки, и мемоизация спасает только строки с идентичными пропсами — что фильтр ломает по самой своей сути. Браузер платит O(n) постоянно: ~30 узлов на строку — это ~300 000 DOM-узлов, раздувающих память, пересчёт стилей и лейаут; именно поэтому React.memo-кампания команды из истории сдвинула 1,3 с до 1,1 с и остановилась. Виртуализация чинит сам объём: рендерятся только видимые ~25 строк плюс небольшой overscan, абсолютно позиционированные через translateY внутри div-распорки, чья высота (getTotalSize) держит скроллбар честным. TanStack Virtual даёт механику: estimateSize как стартовая догадка, measureElement, заменяющий догадки реальными высотами динамических строк (плохая оценка заставляет скроллбар прыгать по мере прихода поправок), overscan 3–10, прикрывающий быстрый скролл и не выкупающий проблему объёма обратно. Два пункта дисциплины: ключуйте строки по id элемента, никогда по виртуальному индексу, иначе состояние компонента перетечёт на те данные, что вскроллятся в переиспользованный слот; и sticky-шапки должны жить вне трансформированных строк. Когда контент статичен и семантичен — длинные статьи, выдача CMS — content-visibility: auto с contain-intrinsic-size даёт альтернативу в одну строку: браузер пропускает стили, лейаут и отрисовку вне экрана, DOM остаётся настоящим (поиск по странице работает), но цена апдейтов React не тронута — все 10 000 компонентов по-прежнему рендерятся. Браузерная боль на статике лечится CSS-ом; реактовская боль апдейтов на живых данных — рефакторингом. Теперь, когда увидишь список, который тормозит на каждом нажатии клавиши, сначала проверишь количество DOM-узлов — и только потом потянешься за React.memo.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.