Виртуализация тысяч файлов, и почему grid — самая сложная часть

Как на самом деле работает windowing, почему grid сложнее списка и какая render-работа React добирает остаток пути до 60fps — memo, мемоизированные селекторы и понимание, где это карго-культ.

Папка на двенадцать тысяч файлов в облачном диске — не пограничный случай, а обычный вторник для всякого, кто хоть раз сгрузил в одно место всю фотоплёнку. В файловом менеджере Облака Mail.ru интерфейс рассыпался именно на таких папках: первый рендер большой папки держал главный поток большую часть секунды, скролл болтался где-то в районе двадцати кадров в секунду, а память вкладки росла с каждой открытой папкой и обратно толком не возвращалась. Причина была до скуки простой — мы рендерили каждый файл. Двенадцать тысяч строк, или двенадцать тысяч карточек grid, разом в DOM, независимо от того, доскроллит до них пользователь или нет.

Лечится это виртуализацией, и принцип умещается в одно предложение: рендерим только то, что на экране, плюс небольшой буфер. Сложное не в принципе. Сложное в том, что список — это легко, а grid — нет, и что одна виртуализация до 60fps не доводит: доводит render-работа React вокруг неё. Разберу и то и другое, и держится тут та же дисциплина, что и везде в перформанс-работе: не оптимизируй то, что предварительно не профилировал.

Как на самом деле работает windowing

Узкое место — DOM, а не ваши данные. Большой массив в памяти браузер держит спокойно; с чем ему плохо — так это с двенадцатью тысячами живых DOM-нод, потому что каждая несёт layout-боксы, разрешение стилей, отрисовку и место в дереве, которое React сверяет на каждом обновлении. Тянет вниз число нод, а не число строк.

Windowing разрывает связь между ними. Вы держите один скролл-контейнер с одним высоким внутренним элементом — спейсером, высота которого равна полной высоте контента, чтобы скроллбар вёл себя так, будто на месте всё. Внутри рендерите только те строки, чей вертикальный диапазон пересекает вьюпорт, абсолютно спозиционированные на вычисленном офсете, и пересчитываете этот срез при изменении scrollTop. Тридцать нод на экране вместо двенадцати тысяч, какого бы размера ни была папка. Память выходит на полку, первый рендер ограничен вьюпортом, а реконсиляция трогает только горстку строк в окне.

Гладкий windower от мерцающего отделяют две детали. Первая — overscan: рендерить несколько строк за каждым краем вьюпорта, чтобы резкий флик не обогнал рендерер и не показал полосу пустоты. Мало — пустота видна; много — рендерите строки, до которых никто не доберётся. Нужное число маленькое, и находится оно скроллом, а не рассуждением.

Вторая — ключи, и эта деталь кусает тихо. Ключуйте строки по стабильному id файла, а не по индексу. На чистом скролле разница почти не видна: окно едет при любой схеме, и React переиспользует большинство нод, меняя только крайние строки. Ломается всё в момент пересортировки: отсортируйте по дате, переключите фильтр — и с ключами по индексу React сопоставляет новые элементы старому DOM по позиции, переиспользуя каждую ноду под тот файл, что теперь оказался на этом индексе. Нода остаётся, файл под ней меняется. Всё, что строка держала локально, — подсветку выделения, наполовину набранное переименование, высоту, которую вы только что для неё замерили, — теперь приклеено к чужому файлу. Стабильные id заставляют React тащить ноду вместе с её файлом, а не оставлять на месте.

Список — это легко. Grid — нет.

Список фиксированной высоты — лёгкий случай, и стоит увидеть почему, прежде чем обретёт смысл сложный. Все строки одной высоты, поэтому офсет элемента — это просто index * rowHeight, полная высота — count * rowHeight, а видимый срез — два деления: первый индекс scrollTop / rowHeight, последний (scrollTop + viewportHeight) / rowHeight. Нечего замерять и не нужно держать состояние на элемент.

Ломают это три вещи, и настоящий grid ломает все три разом.

Переменные высоты. Как только строка может быть высокой или низкой — имя файла, переносящееся на две строки, превью, которого может и не быть, — вы больше не знаете офсет элемента, не зная высоту каждого элемента над ним. Вы прикидываете, рендерите, замеряете, что реально получилось, и сохраняете. Офсеты становятся бегущей суммой известных-или-оценённых высот, а полная высота остаётся вашей лучшей оценкой, пока всё не замерено хотя бы раз.

Замеры. Реальную высоту строки вы узнаёте только после рендера, поэтому замеряете её в layout-эффекте — или через ResizeObserver, ведь превью, догрузившееся позже, меняет высоту, — и пишете в кэш. Кэшируйте по id файла, а не по индексу: замеры по индексу протухают в ту же секунду, когда список пересортировался или отфильтровался, и вы получаете строки, спозиционированные с чужой высотой. Когда замеренная высота расходится с оценкой, которой вы пользовались, все офсеты ниже сдвигаются; а если та строка была над вьюпортом — контент под курсором пользователя прыгнет, если вы не скорректируете scrollTop на дельту в том же кадре. Правильно сделанный scroll anchoring — это и есть большая часть того, что делает виртуализацию переменной высоты пригодной к работе; сделаете неправильно — и список дёргается каждый раз, когда что-то над линией сгиба домеряется.

Два измерения. Grid добавляет ко всему этому колонки. С фиксированными ячейками это ещё арифметика — элементов в ряду floor(containerWidth / cellWidth), ряд карточки floor(index / perRow), — но perRow меняется на каждом ресайзе, так что вся раскладка есть функция ширины и должна пересчитываться вместе с контейнером. Дайте ячейкам разную высоту — и колонки перестают делить общую базовую линию: каждая заполняется независимо, их низы расходятся, и «какой ряд на этой позиции скролла» — уже не одно деление; вы ведёте офсет на колонку и спрашиваете, какие ячейки по всем колонкам пересекают вьюпорт. Это задача masonry, и именно тут самописная виртуализация обычно начинает протекать пограничными случаями.

// офсеты из замеренных-или-оценённых высот, ключ — id файла
const measured = new Map<string, number>();        // id -> реальная высота, как только увидели

function rowOffsets(ids: string[], estimate: number) {
  const offsets = new Array(ids.length);
  let running = 0;
  for (let i = 0; i < ids.length; i++) {
    offsets[i] = running;
    running += measured.get(ids[i]) ?? estimate;     // откатываемся на оценку
  }
  return { offsets, totalHeight: running };
}

Этот O(n)-проход нормален, пока вы гоняете его при появлении нового замера или изменении списка, а не на каждое событие скролла. Офсеты — производное состояние: мемоизируйте их, читайте на скролле и пересчитывайте только когда реально сдвинулся вход. Прогоните цикл на каждый кадр — и вы заново отстроили ту per-frame работу, ради удаления которой всё и затевалось.

Ничего беспрецедентного тут нет — react-window, react-virtualized и TanStack Virtual это решили, вплоть до частей, которых в примере из блога не бывает: субпиксельное округление, инерционный скролл на iOS и десяток мелочей, с которыми знакомишься только в проде. Самописный windower я делал ровно один раз — чтобы разобраться; в продакшне беру поддерживаемую библиотеку и трачу силы на кэш замеров и на компоненты ячеек, где и живёт продуктовая боль.

Render-работа вокруг

Windowing сбивает число нод. Сам по себе до 60fps он не доводит, потому что каждый скролл пересчитывает срез и перерендеривает windowed-контейнер, — а если этот перерендер тащит за собой строки, вы просто переложили цену с «двенадцать тысяч нод один раз» на «тридцать нод шестьдесят раз в секунду». Закрывают этот разрыв три куска работы React.

memo на строки. Оберните строку или ячейку в React.memo, чтобы при сдвиге окна реально реконсилились только вошедшие и вышедшие строки — оставшиеся на месте отваливаются на проверке memo. Это самая высокорычажная оптимизация в виртуализированном списке, и она же — чаще всего ломаемая по неосторожности: memo сравнивает пропсы по ссылке, и в момент, когда вы передаёте строке инлайновый onClick={() => select(id)} или свежий объект style={{}}, она перерендеривается на каждом кадре скролла как ни в чём не бывало. memo и референсная стабильность его пропсов — это одна оптимизация, а не две.

Мемоизируйте селекторы. Windowed-список читает производный срез состояния — файлы текущей папки, отсортированные и отфильтрованные. Если селектор возвращает новый массив на каждый рендер, список перерендеривается на каждый рендер, и ваши мемоизированные строки впустую считают диффы против свежих ссылок. Мемоизированный селектор (reselect, createSelector из RTK) над нормализованным стором возвращает ту же ссылку, пока его входы не изменились, — это и есть то, что позволяет всей цепочке ниже стоять на месте. Этот шаг легко пропустить, и именно из-за пропуска React.memo выше по дереву так и не срабатывает — строки всё равно диффают свежие массивы, что бы вы с ними ни делали.

Дайте обновлениям батчиться. Скролл или мультиселект могут вызвать несколько обновлений состояния подряд, и вы хотите, чтобы они закоммитились один раз, а не по разу каждое. React 18 автоматически батчит обновления в обработчиках, таймаутах и промисах, так что почти всё это теперь бесплатно, — но как только позиция скролла живёт во внешнем сторе или сырой подписке, вы снова следите за тем, чтобы всплеск обновлений схлопнулся в один коммит, а не в рендер на событие.

И сеньорная половина всех трёх: понимать, где это карго-культ. useMemo и useCallback не бесплатны — они аллоцируют, держат массив зависимостей и прогоняют сравнение на каждом рендере. Обернуть в memo лист, который перерендеривается дважды за сессию, или мемоизировать значение, которое не пересекает ни одной memo-границы, — дороже, чем экономит, и не приносит ничего, кроме шума в диффе. Правило, которого держусь: мемоизация оправдывает себя на горячем пути — строке, которая рендерится шестьдесят раз в секунду, — и почти нигде больше. Везде в остальном — докажите Profiler’ом, прежде чем тянуться за ней.

// стабильная идентичность: строка перерендеривается только когда меняются её данные
const FileRow = memo(function FileRow({ file, onSelect }: FileRowProps) {
  return (
    <div className="row" onClick={() => onSelect(file.id)}>
      {file.name}
    </div>
  );
});

// onSelect стабилен между рендерами, поэтому memo на FileRow реально держится
const onSelect = useCallback((id: string) => dispatch(select(id)), [dispatch]);

Инлайновая стрелка внутри FileRow, к слову, безвредна — она создаётся на собственном рендере строки, а он случается только при изменении её пропсов. memo ломает свежая ссылка, переданная внутрь мемоизированного компонента, вроде onSelect; что компонент делает с инлайновым обработчиком на обычном <div> у себя внутри — не стоит ничего.

Стоит увидеть куски в одном месте — порознь они выглядят солиднее, чем есть. Офсеты кормят окно, окно рендерит мемоизированные строки, а обработчик скролла только двигает число:

// upperBound — стандартный бинпоиск; dispatch — dispatch вашего стора;
// .viewport задаёт height + overflow-y: auto в CSS.
function VirtualFileList({ ids, files }: VirtualListProps) {
  const [scrollTop, setScrollTop] = useState(0);
  const viewportH = 600;                  // в реальном коде замеряется у контейнера
  const OVERSCAN = 4;

  // выводится раз на изменение списка, а не на кадр скролла. NB: rowOffsets читает
  // мутабельный кэш `measured` — когда ниже добавите эффект замеров, новая высота не
  // изменит `ids`, так что заведите в эти зависимости счётчик версии, иначе мемо протухнет.
  const { offsets, totalHeight } = useMemo(() => rowOffsets(ids, 48), [ids]);

  // первая/последняя видимая строка — бинарным поиском по кумулятивным офсетам
  const first = Math.max(0, upperBound(offsets, scrollTop) - 1 - OVERSCAN);
  const last = Math.min(ids.length, upperBound(offsets, scrollTop + viewportH) + OVERSCAN);

  const onSelect = useCallback((id: string) => dispatch(select(id)), [dispatch]);

  return (
    <div className="viewport" onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {ids.slice(first, last).map((id, i) => (
          <div key={id} style={{ position: 'absolute', top: offsets[first + i], width: '100%' }}>
            <FileRow file={files[id]} onSelect={onSelect} />
          </div>
        ))}
      </div>
    </div>
  );
}

Чего в этих тридцати строках нет — так это эффекта замеров, наполняющего measured по мере рендера строк (ResizeObserver на строку, пишущий в кэш по id), и коррекции scroll anchoring, когда меняется высота над вьюпортом. Подключите этот эффект — и наступите на капкан из комментария: он пишет сквозь кэш, не трогая ids, поэтому мемо офсетов должно ключаться на версию, тикающую на каждую запись, иначе новых высот оно не увидит. И это всё ещё одноколоночный список — masonry-grid из заголовка, с независимыми офсетами колонок, это та самая часть, под которую я беру библиотеку. Эти куски сами по себе достаточно муторны, чтобы стать большей частью причины, по которой я беру её, а не отгружаю блок выше.

А кадры реально легли?

Число нод в инспекторе — не цель; цель — скролл, который не дёргается на среднем телефоне, и чтобы это знать, за ним надо смотреть.

Что я проверяю, прежде чем назвать список «гладким»

Итак, результат — снятый так, как предписывает раздел выше: 6× CPU-троттлинг в Chrome, самые тяжёлые реальные папки, заскриптованный скролл на фиксированной скорости. Та самая папка на двенадцать тысяч элементов раньше проводила около 740 мс в главном потоке до первой отрисовки, а потом ползла по скроллу на ~22fps. После — первый рендер ограничен вьюпортом и укладывается в ~64 мс, скролл держит 60 с 95-м перцентилем времени кадра под бюджетом в 16,6 мс, а число DOM-нод и память вкладки выходят на полку независимо от размера папки. Чище всего историю рассказал Profiler: коммит windowed-списка на кадре скролла упал с ~41 мс, когда перерендеривалась каждая видимая строка, до примерно 3 мс — после того как строки стали мемоизированными, а селектор перестал раздавать вниз свежие массивы. Структурную половину сделала виртуализация; остальное — memo и стабильный селектор. И числам я не верил, пока кадры не легли ещё и на живом среднем телефоне, а не только на придушенном профиле, — список, «виртуализированный» на бумаге и всё равно дёргающийся на реальном железе, это просто более сложный способ дёргаться.