Узкое место DOM, о котором никто не говорит
Представьте себе панель управления поддержкой, которая подгружает десять тысяч записей логов. Или CRM-систему, пытающуюся отобразить каждый контакт в одной прокручиваемой таблице. В React код для создания такого интерфейса выглядит вполне безобидно. Вы итерируетесь по массиву, возвращаете JSX и позволяете фреймворку делать свою работу. В режиме разработки со ста строками всё работает отлично. Но когда приходят реальные данные из продакшена, страница превращается в кисель.
Браузер не ленится. Он делает именно то, что вы его попросили, и в этом заключается проблема. Каждая строка становится DOM-узлом. Каждый узел получает стили, рассчитывается его положение (layout), происходит отрисовка (paint) и отслеживание в памяти. При прокрутке браузер пересчитывает позиции для всего дерева, а не только для той части, которую вы видите. Обработчики событий накапливаются. Резко растет потребление памяти. В конце концов основной поток (main thread) захлебывается настолько, что интерфейс перестает реагировать на клики, нажатия клавиш или даже на саму прокрутку. Приложение не упало в техническом смысле, но для пользователя, сидящего перед экраном, опыт использования испорчен.
Это происходит потому, что браузер пытается держать каждый элемент в активной памяти одновременно. React может быть эффективен при создании виртуальных описаний вашего UI, но как только эти описания становятся реальными узлами в документе, они стоят столько же, сколько написанный вручную HTML. В самом фреймворке нет встроенного механизма для обхода этой проблемы. Вам нужно структурное изменение в том, как вы подаете список в DOM.
Что на самом деле означает виртуализация
Виртуализация — это и есть то самое структурное изменение. Вместо того чтобы просить React отрендерить весь массив, вы рендерите только те элементы, которые могут поместиться в области видимости (viewport), плюс небольшой буфер сверху и снизу. По мере прокрутки приложение отбрасывает узлы, выходящие из поля зрения, и создает новые, появляющиеся с противоположного края. Для пользователя это всё равно выглядит как один непрерывный список, потому что общая высота прокрутки сохраняется — обычно за счет одного высокого контейнера или тщательно рассчитанного спейсера (spacer). Видимые элементы — это просто окно, скользящее по набору данных.
Представьте себе кинопленку, проходящую через затвор проектора. Зритель видит плавное движение, но механизм освещает только тот кадр, который находится в данный момент в позиции. Остальная часть катушки находится на подающем и принимающем валах, а не на пути света. Виртуализированные списки работают так же. Набор данных — это катушка. Область видимости — это затвор.
Это не ленивая загрузка (lazy loading) в традиционном понимании. Lazy loading откладывает получение данных до тех пор, пока пользователь не прокрутит к ним близко. Виртуализация предполагает, что данные у вас уже есть, но вы выборочно решаете, какие фрагменты будут преобразованы в реальные DOM-элементы. Эти две техники могут работать вместе, но они решают разные проблемы.
Почему разница ощущается мгновенно
Преимущества проявляются в четырех аспектах, и все они связаны с одним и тем же: вы перестаете платить за то, что пользователь не видит.
Ускорение начальной загрузки. Когда браузер открывает страницу, он отрисовывает, скажем, пятнадцать строк вместо пятнадцати тысяч. Первая значимая отрисовка (first meaningful paint) происходит быстрее. Время до интерактивности (time-to-interactive) сокращается, так как JavaScript-движку требуется меньше времени на создание узлов и их прикрепление к документу.
Снижение потребления памяти. DOM-узел — это дорогостоящий объект. Каждый из них несет ссылки на правила стилей, метрики компоновки и привязки событий. Сократите количество активных узлов до нескольких десятков, и объем занимаемой памяти резко упадет. На слабых устройствах или при длительных сессиях это само по себе может предотвратить закрытие вкладки операционной системой.
Плавная прокрутка. При меньшем количестве узлов в дереве браузер тратит меньше времени на фазы компоновки (layout) и отрисовки (paint) во время событий прокрутки. Поток композитора (compositor thread) может обрабатывать движение, не пересчитывая постоянно геометрию скрытого контента. Результат — прокрутка, максимально приближенная к частоте обновления монитора.
Стабильная частота кадров. Поскольку основной поток больше не тонет в задачах по компоновке, появляется запас ресурсов для другой активности. Анимации остаются плавными. Ответы сети могут обрабатываться. Интерфейс не замирает при поступлении новых данных, потому что путь рендеринга больше не является узким местом.
Как правильно реализовать
В экосистеме React такие библиотеки, как react-window и более тяжеловесная react-virtualized, предоставляют механизмы для реализации этого паттерна. Основная идея неизменна: вы определяете рендерер элемента, передаете общее количество элементов, а библиотека берет на себя математические расчеты окна. Но именно на деталях многие спотыкаются.
Во-первых, контейнеру необходима фиксированная высота. Если список находится внутри родительского элемента, который расширяется под содержимое, виртуализация не сможет рассчитать, какие элементы видны, так как отсутствует граница вьюпорта. Вы должны ограничить список фиксированной высотой или flex-контейнером с известными ограничениями.
Во-вторых, размер элементов имеет огромное значение. Строки фиксированной высоты — это самый простой случай. Библиотека умножает высоту строки на индекс и точно знает, где расположить каждый элемент. Контент переменной высоты, например, сообщения в чате с изображениями или ветки комментариев, заставляет библиотеку производить замеры после монтирования и корректировать положение на лету. Этот этап замера может вызвать дрожание скролла, если он происходит слишком поздно. Если ваши данные позволяют, используйте единообразную или минимальную высоту. Если нет — используйте виртуализатор с переменной высотой, смирившись с дополнительной сложностью.
В-третьих, оверсканнинг (overscan) — ваш друг. Отрисовка ровно того, что помещается на экране, приводит к появлению пустых белых полос при быстрой прокрутке. Большинство библиотек позволяют отрисовывать несколько лишних элементов выше и ниже видимой области. Двух или трех строк оверсканнинга обычно достаточно, чтобы скрыть стыки, не раздувая при этом DOM.
В-четвертых, не игнорируйте проп key. В виртуализированном списке элементы повторно используют DOM-узлы при прокрутке. Стабильные ключи предотвращают ошибки React при реконсиляции, которые могут привести к уничтожению состояния внутри компонентов строк. Если ваши строки списка содержат инпуты, переключатели или раскрывающиеся секции, плохие ключи повредят состояние UI так, что это будет выглядеть как баг в слое данных, хотя на самом деле это ошибка рендеринга.
Одна из тонких ловушек — браузерный поиск по странице. Поскольку скрытые элементы отсутствуют в DOM, поиск в браузере их не увидит. Если ваши пользователи полагаются на Ctrl+F для поиска текста в большом списке, вам придется создать собственный поиск, который работает с набором данных, а не с документом. Скринридеры также могут терять контекст, если семантика списка обработана небрежно, поэтому тестируйте с помощью вспомогательных технологий и рассмотрите возможность добавления уведомлений в live-регионы для динамической загрузки.
Когда стоит от нее отказаться
Виртуализация не бесплатна. Она добавляет вес зависимостей, математические расчеты координат и накладные расходы на ограничения. Если ваш список насчитывает пятьдесят или сто элементов, браузер справится с этим без посторонней помощи. Просто отрисуйте всё и идите дальше. То же самое относится к случаям, когда сами элементы списка чрезвычайно сложны. Виртуализация спасает от тысяч узлов, но она не спасет вас от одного узла, содержащего массивную диаграмму или видеоэлемент. Сначала исправьте раздувание самих элементов.
Также избегайте виртуализации, если список не прокручивается. Если вы используете пагинацию с кнопками «вперед» и «назад» и показываете только двадцать элементов на странице, здесь нечего виртуализировать. Этот метод оправдывает себя только тогда, когда пользователь ожидает прокрутки через длинную непрерывную последовательность.
Главный вывод
Виртуализация — это не столько выбор библиотеки, сколько образ мышления. Она заставляет вас признать, что DOM — это конечный ресурс, а не бесконечный холст. Прежде чем внедрять ее, откройте Chrome DevTools, запишите профиль производительности и убедитесь, что причиной действительно является время компоновки (layout) или отрисовки (paint). Как только вы поймете, что узким местом является DOM, примите ограничения. Фиксируйте высоту, следите за ключами, используйте умеренный оверсканнинг и тестируйте доступность. При правильном подходе виртуализированный список превращает нечитаемую стену данных в нечто, работающее так же легко, как нативный скролл. Браузер перестает сопротивляться, пользователи перестают ждать, а приложение наконец начинает работать как быстрый интерфейс, который вы планировали создать.
