Вкратце: используйте элементы DOM повторно и удалите те, которые находятся далеко от области просмотра. Используйте плейсхолдеры для учёта задержек данных. Вот демо и код для бесконечной прокрутки.
Бесконечные скроллеры появляются по всему интернету. Список исполнителей в Google Music — один из них, хроника Facebook — один из них, и прямая трансляция в Twitter — тоже. Прокручиваешь вниз, и ещё до того, как дойдешь до конца, словно из ниоткуда появляется новый контент. Это удобный и понятный опыт для пользователей, и он легко объясним.
Однако техническая задача, стоящая за бесконечной полосой прокрутки, сложнее, чем кажется. Спектр проблем, с которыми вы сталкиваетесь, стремясь к «Правильным действиям™», огромен. Всё начинается с простых вещей, например, ссылок в нижнем колонтитуле, которые становятся практически недоступными из-за того, что контент постоянно вытесняет их. Но проблемы становятся сложнее. Как обрабатывать событие изменения размера, когда пользователь переворачивает телефон из портретной ориентации в альбомную, или как предотвратить зависание телефона при слишком длинном списке?
Правильная вещь™
Мы посчитали, что это достаточная причина для разработки эталонной реализации, показывающей способ решения всех этих проблем с возможностью повторного использования и сохранением стандартов производительности.
Для достижения нашей цели мы будем использовать 3 метода: переработку DOM, надгробные камни и привязку прокрутки.
В качестве демо-примера мы будем использовать окно чата, похожее на Hangouts, где можно прокручивать сообщения. Первое, что нам понадобится, — это бесконечный источник сообщений чата. Технически, ни один из бесконечных скроллеров не является по-настоящему бесконечным, но, учитывая объём данных, которые можно загрузить в эти скроллеры, они вполне могли бы быть таковыми. Для простоты мы просто зададим набор сообщений чата и будем случайным образом выбирать сообщение, автора и иногда прикреплённые изображения с небольшой искусственной задержкой, чтобы поведение было более похоже на поведение настоящей сети.

переработка ДОМ
Переработка DOM — это недооценённый метод поддержания небольшого количества DOM-узлов. Основная идея заключается в использовании уже созданных DOM-элементов, находящихся вне экрана, вместо создания новых. Конечно, сами DOM-узлы недороги, но они не бесплатны, поскольку каждый из них добавляет дополнительные затраты памяти, макета, стиля и отрисовки. Устройства начального уровня станут заметно медленнее, если не полностью непригодными для использования, если DOM-узел сайта слишком большой для управления. Также имейте в виду, что каждое перераспределение и повторное применение стилей — процесс, запускаемый при добавлении или удалении класса из узла — дорожает с увеличением DOM. Переработка DOM-узлов означает, что мы будем значительно сокращать общее количество DOM-узлов, ускоряя все эти процессы.
Первое препятствие — это сама прокрутка. Поскольку в любой момент времени у нас будет лишь крошечное подмножество всех доступных элементов DOM, нам нужно найти другой способ заставить полосу прокрутки браузера правильно отражать теоретически доступный объём контента. Мы будем использовать элемент-ограничитель размером 1x1 пиксель с преобразованием, чтобы принудительно задать нужную высоту элементу, содержащему элементы — бегущей строке (подиуму). Мы переместим каждый элемент бегущей строки (подиуму) в отдельный слой, чтобы убедиться, что слой бегущей строки (подиума) полностью пуст. Никакого фонового цвета, ничего. Если слой бегущей строки не пустой, он не подпадает под оптимизацию браузера, и нам придётся хранить на нашей видеокарте текстуру высотой в несколько сотен тысяч пикселей. Это определённо непрактично на мобильном устройстве.
При каждой прокрутке мы проверяем, достаточно ли близко область просмотра подошла к концу полосы. Если да, мы удлиняем полосу, перемещая элемент-ограничитель и перемещая элементы, вышедшие за пределы области просмотра, в нижнюю часть полосы, заполняя их новым контентом.
То же самое касается прокрутки в обратном направлении. Однако в нашей реализации мы никогда не будем сужать полосу прокрутки, чтобы положение полосы прокрутки оставалось неизменным.
Надгробия
Как мы уже упоминали ранее, мы стараемся, чтобы наш источник данных вёл себя как нечто из реального мира. С учётом задержек сети и всего остального. Это означает, что если наши пользователи используют плавную прокрутку, они могут легко пролистать страницу мимо последнего элемента, для которого у нас есть данные. В этом случае мы разместим элемент-заполнитель (tombstone) – заглушку, – который будет заменён элементом с реальным содержимым после получения данных. Элементы-заместители также перерабатываются и имеют отдельный пул для повторно используемых DOM-элементов. Это необходимо для того, чтобы обеспечить плавный переход от элемента-заместителя к элементу, заполненному содержимым, что в противном случае было бы очень раздражающим для пользователя и могло бы фактически заставить его потерять фокус.

Интересная проблема здесь заключается в том, что реальные элементы могут иметь большую высоту, чем элемент-надгробие, из-за разного количества текста в каждом элементе или прикреплённого изображения. Чтобы решить эту проблему, мы будем корректировать текущее положение прокрутки каждый раз при поступлении данных и смене элемента-надгробия над областью просмотра, привязывая положение прокрутки к элементу, а не к значению в пикселях. Эта концепция называется привязкой прокрутки.
Привязка прокрутки
Наша привязка к прокрутке будет активироваться как при замене надгробных камней, так и при изменении размера окна (что также происходит при перевороте устройств!). Нам нужно будет определить, какой элемент в области просмотра является самым верхним видимым. Поскольку этот элемент может быть виден лишь частично, мы также сохраним смещение от верхней части элемента, где начинается область просмотра.

Если размер области просмотра изменён, а полоса прокрутки изменилась, мы можем восстановить ситуацию, которая визуально кажется пользователю идентичной. Победа! За исключением того, что изменённое окно означает, что каждый элемент потенциально изменил свою высоту, так как мы узнаем, насколько низко должен быть помещен закреплённый контент? Мы не знаем! Чтобы узнать это, нам пришлось бы размещать каждый элемент над закреплённым элементом и складывать все их высоты; это может вызвать значительную паузу после изменения размера, а нам это не нужно. Вместо этого мы прибегаем к предположению, что каждый элемент выше имеет тот же размер, что и надгробный камень, и соответствующим образом корректируем нашу позицию прокрутки. По мере того, как элементы прокручиваются в полосу прокрутки, мы корректируем нашу позицию прокрутки, фактически откладывая работу по компоновке до того момента, когда она действительно понадобится.
Макет
Я упустил важную деталь: макет. Каждый повторный запуск DOM-элемента обычно перерисовывал бы всю полосу, что значительно снизило бы нашу целевую частоту в 60 кадров в секунду. Чтобы избежать этого, мы берём на себя бремя макетирования и используем абсолютно позиционированные элементы с трансформациями. Таким образом, мы можем делать вид, что все элементы, расположенные выше по полосе, всё ещё занимают место, хотя на самом деле там только пустое пространство. Поскольку мы сами занимаемся макетированием, мы можем кэшировать позиции, в которых оказывается каждый элемент, и сразу загружать нужный элемент из кэша, когда пользователь прокручивает страницу назад.
В идеале элементы должны перерисовываться только один раз при присоединении к DOM и не реагировать на добавление или удаление других элементов на подиуме. Это возможно, но только в современных браузерах.
Новейшие усовершенствования
Недавно в Chrome появилась поддержка CSS Containment — функции, которая позволяет разработчикам сообщать браузеру, что элемент является границей для вёрстки и отрисовки. Поскольку мы сами занимаемся вёрсткой, это идеальное применение для ограничения. Добавляя элемент в Runway, мы знаем, что другие элементы не должны быть затронуты Layout. Поэтому для каждого элемента следует задать директиву get contain: layout
. Мы также не хотим, чтобы это повлияло на остальную часть нашего сайта, поэтому для Runway также должна быть задана эта директива стиля.
Мы также рассматривали возможность использования IntersectionObservers
в качестве механизма определения того, когда пользователь прокрутил страницу достаточно далеко, чтобы начать перезапуск элементов и загрузку новых данных. Однако IntersectionObservers имеют высокую задержку (как при использовании requestIdleCallback
), поэтому с IntersectionObservers мы можем ощущать себя менее отзывчивыми, чем без него. Даже наша текущая реализация с использованием события scroll
страдает от этой проблемы, поскольку события прокрутки обрабатываются по принципу «максимальных усилий». В конечном итоге, Compositor Worklet от Houdini станет высокоточным решением этой проблемы.
Это все еще не идеально.
Наша текущая реализация переработки DOM не идеальна, поскольку она добавляет все элементы, проходящие через область просмотра, вместо того, чтобы учитывать только те, которые действительно находятся на экране. Это означает, что при очень быстрой прокрутке вы загружаете Chrome таким количеством работы по компоновке и отрисовке, что он не справляется. В итоге вы будете видеть только фон. Это не конец света, но определённо есть над чем работать.
Надеемся, вы понимаете, насколько сложными могут стать простые задачи, когда вы хотите совместить отличный пользовательский опыт с высокими стандартами производительности. Поскольку прогрессивные веб-приложения становятся неотъемлемой частью мобильных устройств, это становится всё более важным, и веб-разработчикам придётся продолжать инвестировать в использование шаблонов, учитывающих ограничения производительности.
Весь код можно найти в нашем репозитории . Мы сделали всё возможное, чтобы сохранить возможность его повторного использования, но не будем публиковать его как отдельную библиотеку в npm или в виде отдельного репозитория. Он предназначен в первую очередь для образовательных целей.