Виртуализация тысяч файлов, и почему grid — самая сложная часть
Как на самом деле работает windowing, почему grid сложнее списка и какая render-работа React добирает остаток пути до 60fps — memo, мемоизированные селекторы и понимание, где это карго-культ.
Папка на двенадцать тысяч файлов в облачном диске — не пограничный случай, а обычный вторник для всякого, кто хоть раз сгрузил в одно место всю фотоплёнку. В файловом менеджере Облака Mail.ru интерфейс рассыпался именно на таких папках: первый рендер большой папки держал главный поток большую часть секунды, скролл болтался где-то в районе двадцати кадров в секунду, а память вкладки росла с каждой открытой папкой и обратно толком не возвращалась. Причина была до скуки простой: мы рендерили каждый файл. Двенадцать тысяч строк или двенадцать тысяч карточек grid разом в DOM — независимо от того, доскроллит до них пользователь или нет.
Лечится это виртуализацией. Принцип умещается в одно предложение: рендерим только то, что на экране, плюс небольшой буфер. Трудность не в принципе, а в деталях. Список виртуализировать легко; grid — заметно сложнее; и даже работающее окно само по себе до 60fps не дотягивает — остаток пути добирает render-работа React вокруг него. Дальше разбираю обе части. Дисциплина тут та же, что и в любой перформанс-работе: не оптимизируй то, чего не профилировал.
Как на самом деле работает windowing
Узкое место — это DOM. Объём данных почти ни при чём: большой массив браузер держит в памяти спокойно. Тяжело ему даётся другое — двенадцать тысяч живых DOM-нод, потому что каждая несёт layout-боксы, разрешение стилей, отрисовку и место в дереве, которое React сверяет на каждом обновлении. Решает число нод, а не число строк.
Windowing разрывает эту связь. Вы держите один скролл-контейнер с единственным высоким внутренним элементом — спейсером, высота которого равна полной высоте контента, чтобы скроллбар вёл себя так, будто отрисовано всё. Внутри рендерите только те строки, чей вертикальный диапазон пересекает вьюпорт, абсолютно спозиционированные на вычисленном офсете, и пересчитываете этот срез при изменении scrollTop. Офсет задавайте через transform: translateY(): в отличие от top, такой сдвиг уходит на композитор, не дёргая layout и paint. На экране тогда тридцать нод вместо двенадцати тысяч, какого бы размера ни была папка. Память выходит на полку, первый рендер ограничен вьюпортом, а реконсиляция трогает только горстку строк в окне.
Рядом напрашивается content-visibility: auto, и стоит сразу проговорить, почему здесь он не помощник. Свойство пропускает layout и paint у контента за экраном, но сами ноды оставляет в DOM. А мучает нас именно их число и память под них — поэтому эту задачу content-visibility не закрывает: он про стоимость рендера off-screen контента, а не про размер дерева.
Гладкий 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-контейнер, и если этот перерендер тащит за собой строки, цена просто переехала с «двенадцать тысяч нод один раз» на «тридцать нод шестьдесят раз в секунду». Дальше за дело берётся render-работа 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 — стандартный бинпоиск; useDispatch — из react-redux/RTK;
// .viewport задаёт height + overflow-y: auto в CSS.
function VirtualFileList({ ids, files }: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);
const dispatch = useDispatch();
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: 0, transform: `translateY(${offsets[first + i]}px)`, width: '100%' }}>
<FileRow file={files[id]} onSelect={onSelect} />
</div>
))}
</div>
</div>
);
}
Чего в этих тридцати строках нет — так это эффекта замеров, наполняющего measured по мере рендера строк (ResizeObserver на строку, пишущий в кэш по id), и коррекции scroll anchoring, когда меняется высота над вьюпортом. Подключите этот эффект — и наступите на капкан из комментария: он пишет сквозь кэш, не трогая ids, поэтому мемо офсетов должно ключаться на версию, тикающую на каждую запись, иначе новых высот оно не увидит. И это всё ещё одноколоночный список. Masonry-grid из заголовка, с независимыми офсетами колонок, — та самая часть, под которую я беру библиотеку: куски эти достаточно муторны сами по себе, чтобы я предпочёл готовое решение, а не блок выше.
Что виртуализация ломает в доступности
Окно держит в DOM три десятка строк из двенадцати тысяч, и ровно поэтому ломается несколько вещей, которые на обычном списке работают сами собой.
Скринридер видит только смонтированное. Для ассистивных технологий список из двенадцати тысяч элементов выглядит как список из тридцати. Лечится это явной семантикой: контейнеру — подходящую по смыслу роль (listbox, grid, table), а каждой строке — aria-setsize с полным числом элементов и aria-posinset с её настоящим индексом. Тогда скринридер произносит «3 из 12 000», а не «3 из 30».
Фокус живёт на узле. Сфокусированная строка уезжает за край вьюпорта и размонтируется — фокус вместе с активным элементом схлопывается на <body>, и пользователь вылетает из клавиатурной навигации. Этим надо управлять: вести roving tabindex, возвращать фокус, когда строка примонтируется обратно, или принудительно держать активную строку в окне.
Клавиатурная навигация поверх этого. Стрелка вниз на последней видимой строке должна сперва подвинуть scrollTop, чтобы целевая строка вошла в окно и смонтировалась, и только потом перевести на неё фокус — её ведь может сейчас вообще не быть в DOM. На обычном списке об этом не думаешь. На виртуализированном это отдельная работа, и без неё с клавиатуры по списку не пройти.
А кадры реально легли?
Число нод в инспекторе — не цель. Цель — скролл без рывков на среднем телефоне, а чтобы знать, что он именно такой, за ним надо следить инструментами.
- React Profiler говорит, что перерендерилось и сколько занял каждый коммит. Запишите скролл — и флеймграф скажет сразу: если на каждом кадре загорается каждая видимая строка, протекает ваша мемоизация или селектор; если коммитятся только входящие и выходящие — windowing делает своё дело.
- Панель Performance говорит, попадаете ли вы в бюджет кадра. На 60fps у вас ~16,6 мс на кадр под скрипт, layout и paint вместе; заскриптованный скролл, на котором видны long tasks или forced reflow — чтение layout в том же тике, где вы в него писали, — там и берутся выпавшие кадры. Диагональ выпавших кадров в дорожке frames — это то, что надо убирать.
- Честное число — выпавшие кадры под фиксированным скроллом, а не средний FPS. Среднее прячет рывки; чувствуется пользователем 95-й перцентиль времени кадра. Прижмите CPU в 4–6 раз, проскролльте по-настоящему большую папку с ровной скоростью и посчитайте кадры, вылетевшие за бюджет.
Что я проверяю, прежде чем назвать список «гладким»
- число DOM-нод не растёт с папкой — папка на 200 и на 20 000 элементов держат на экране одну и ту же горстку строк;
- строки ключуются по id файла, чтобы окно ехало, не снося и не пересобирая DOM;
- overscan подобран скроллом, а не угадан — ни пустых полос на резком флике, ни рендера строк, до которых никто не доберётся;
- переменные высоты замеряются в кэш по id, и поздний замер над вьюпортом якорит скролл, а не дёргает его;
- строка мемоизирована и каждый её пропс референсно стабилен, так что скролл коммитит только изменившиеся строки;
- доступность переживает виртуализацию: у строк проставлены
aria-setsizeиaria-posinsetпод полный набор, фокус не теряется, когда строка размонтируется на скролле, а клавиатура доезжает до строк, которых сейчас нет в окне; - Profiler показывает на скролле коммит только входящих и выходящих строк, а дорожка frames держит бюджет на придушенном профиле среднего устройства.
Итак, результат — снятый так, как предписывает раздел выше: 6× CPU-троттлинг в Chrome, самые тяжёлые реальные папки, заскриптованный скролл на фиксированной скорости. Та самая папка на двенадцать тысяч элементов раньше проводила около 740 мс в главном потоке до первой отрисовки, а потом ползла по скроллу на ~22fps. После — первый рендер ограничен вьюпортом и укладывается в ~64 мс, скролл держит 60 с 95-м перцентилем времени кадра под бюджетом в 16,6 мс, а число DOM-нод и память вкладки выходят на полку независимо от размера папки. Чище всего историю рассказал Profiler: коммит windowed-списка на кадре скролла упал с ~41 мс, когда перерендеривалась каждая видимая строка, до примерно 3 мс — после того как строки стали мемоизированными, а селектор перестал раздавать вниз свежие массивы. Структурную половину сделала виртуализация; остальное — memo и стабильный селектор. И числам я не верил, пока кадры не легли ещё и на живом среднем телефоне, а не только на придушенном профиле, — список, «виртуализированный» на бумаге и всё равно дёргающийся на реальном железе, это просто более сложный способ дёргаться.