open atlas
↑ К треку
React с нуля до senior RCT · 05 · 02

Цена рендера и виртуализация: таблица на 10 000 строк, рендерящая 30

Объём рендера убивает: таблица на 10k строк — это 300k DOM-узлов и секунда на нажатие. Виртуализация рендерит только viewport плюс overscan (TanStack Virtual: estimateSize, measureElement, translate); content-visibility — CSS-родственник для статики. Ключи — id данных.

RCT Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команде бэк-офиса досталась таблица транзакций, которая «нормально работала на стейджинге». На стейджинге было 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 строк с живыми обновлениями тормозит на каждом тике данных. Какой инструмент к какой проблеме?

Вспомните перед уходом
  1. 01
    Таблица на 10k строк тормозит и при вводе в фильтр, и при скролле. Объясните два разных центра затрат и почему React.memo всерьёз не чинит ни один.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.