Всем привет! Меня зовут Павел Сапачёв, занимаюсь архитектурой и разработкой фронтенда в проекте «Тинькофф Лизинг». Мы любим создавать удобные, отзывчивые и производительные интерфейсы. Один из моментов улучшения — просмотр коллекций элементов. Самые популярные подходы к просмотру коллекций — постраничная разбивка и подгрузка при пролистывании страницы, которую называют бесконечными списками.
Если постраничная разбивка является классическим и понятным представлением навигации, то создание бесконечных списков содержит ряд подводных камней и технических сложностей.
В статье поделюсь реализацией списков на основе Deferrable Views, недавно появившихся в Angular 17.
Методы обнаружения конца списка
Основная проблема при создании бесконечных списков — определение момента, когда пользователю необходимо загрузить и показать следующую часть коллекции элементов. Триггер для загрузки — момент, когда пользователь дошел до конца страницы или последнего элемента в контейнере.
Методы определения конца списка со временем менялись. На заре появления подхода с бесконечными списками, примерно во времена Internet Explorer 10, основным способом была проверка значения свойства scrollTop
у документа или контейнера со списком. Потом значение сравнивали с высотой документа или контейнера.
Примерно тогда же существовали различные вариации определения последнего элемента по его координатам в зоне видимости через обращение к методу getBoundingClientRect()
.
Значительные улучшения принес 2017 год, когда в браузерах началось внедрение Intersection Observer API, в основу которого заложена работа с обращениями к упомянутому методу getBoundingClientRect()
, только с предоставлением более удобного интерфейса.
Intersection Observer API реализует в себе паттерн «Наблюдатель», суть которого в создании подписки на изменения положения наблюдаемого элемента. Используя данные, получаемые при срабатывании подписки, можно реализовать разную логику, в том числе инициацию вызова загрузки следующей части коллекции и ее дальнейшее отображение пользователю.
Перечисленные методы обнаружения конца списка нативные и реализуются браузерами, что накладывает определенные ограничения на используемые версии, особенно в случаях, когда требуется поддержка старых браузеров. Замечание более всего справедливо по отношению к Intersection Observer API, потому что сейчас практически не осталось ситуаций, когда требуется использовать столь древнюю версию браузера, которая не поддерживает даже scrollTop
.
В интернете можно найти множество примеров реализации Intersection Observer API в различных фреймворках и библиотеках. Например, в Angular 16 и более ранних версиях используют кастомные директивы, оборачивающие в себе обращения к API.
В недавнем релизе Angular 17 появились Deferrable Views, с помощью которых стало возможно добавить немного магии и удобства в свои компоненты.
Разработчики Angular при создании Deferrable Views заложили в их основу использование существующих браузерных API, таких как requestIdleCallback
и Intersection Observer API, а еще обертки вокруг обработчиков событий click
, keydown
, mouseenter
, focusin
. Иначе говоря, появился синтаксический сахар, в значительной степени упрощающий работу с перечисленными функциями.
В документации Angular есть примеры использования Deferrable Views с разными триггерами, но в рамках этой статьи нас интересует только триггер on viewport
, который срабатывает в момент, когда элемент появляется в зоне видимости благодаря использованию под капотом Intersection Observer API.
Реализация триггера загрузки
Реализация триггера загрузки — краеугольный камень в организации бесконечных списков. Применяя триггер on viewport
, мы можем с помощью шаблона подсказать компоненту момент, когда нужно вызвать обращение к сервису данных для подгрузки следующей части.
Задачу можно решить разными способами: например, используя изображение, у которого будет прослушиваться событие load
. Оно инициирует запуск вызова к сервису данных:
@defer (on viewport) {
<img (load)=”loadMore()” src=”...” />
}
Недостаток использования изображения в том, что должно быть доступное изображение в статических ресурсах или на CDN, а также накладные расходы при работе с сетью.
Другой способ — загрузка пустого компонента, основная задача которого — уведомление родительского компонента о том, что он был создан при появлении в зоне видимости:
@defer (on viewport) {
<app-load-trigger (init)="loadMore()" />
}
Код триггер-компонента примитивный:
export class LoadTriggerComponent implements OnInit {
@Output()
readonly init = new EventEmitter();
ngOnInit(): void {
this.init.emit();
}
}
Очевидное преимущество этого способа — отсутствие каких-либо обращений по сети для решения задачи средствами самого Angular.
Перестановка триггера в конец списка
Основная сложность по сути является первопричиной: как сделать так, чтобы компонент-триггер был в конце списка и срабатывал каждый раз, когда мы достигаем конца списка?
Ответом будет использование структурной директивы NgFor
, у которой есть ряд замечательных локальных переменных, в том числе boolean-переменная last
.
Благодаря использованию last
можно описать такой шаблон, в котором представление всегда будет выводиться в конце списка и, что важно, перерисовываться каждый раз при обновлении списка, скрывая новое представление из области видимости.
В упрощенном виде без использования нового синтаксиса Control Flow код шаблона будет выглядеть так:
<app-list-item *ngFor="let item of list; last as isLast">
<ng-container *ngIf="isLast">
@defer (on viewport) {
<app-load-trigger (init)="loadMore()" />
} @placeholder {
<div></div>
}
</ng-container>
</app-list-item>
Наличие placeholder
— обязательная часть синтаксиса @defer (on viewport)
и содержит пустой блок, который для нашего случая бесполезен. Особенность реализации в том, что новый компонент-триггер не будет создан повторно, если список не был обновлен. Например, сервис данных не прислал других данных для дополнения коллекции.
Остановка бесконечной загрузки
Дополнительная проблема — определение момента, когда больше не требуется запрашивать данные. Иначе говоря: когда был достигнут конец списка? Частичным ответом будет предыдущий абзац: если массив элементов не обновился, то ничего перерисовывать не нужно и компонент-триггер переставлять тоже не нужно.
В целях оптимизации хорошо бы иметь возможность заранее предусматривать необходимость установки компонента-триггера. Если не полагаться на ответы от сервиса данных, который может сообщить о том, что отдал последнюю часть коллекции элементов, можно самостоятельно проверять в том же NgFor
то, является ли элемент не только последним в списке, но и последним вообще.
Это можно сделать с помощью локальной переменной index
и написать простой метод компонента, который будет помогать в определении:
isLastInBunch(index): boolean {
return (index + 1) % PAGE_SIZE === 0;
}
Суть метода простая: попытаться понять, является ли проверяемый элемент последним в списке, на основании того, кратен ли его индекс порции запрашиваемых элементов коллекции (PAGE_SIZE
). Такой подход не идеальный: может получиться так, что наш список содержит общее кратное количество элементов, но с некоторой долей вероятности решает проблему ненужного последнего запроса.
Демо ленивого бесконечного списка
Я подготовил пример, чтобы показать вживую, как организовать ленивый бесконечный список:
В примере намеренно добавлены задержки на дополнение коллекции для того, чтобы наглядно увидеть момент срабатывания загрузки и отображение сообщения о загрузке.
С целью демонстрации список бесконечный и не содержит примеров остановки загрузки. Используя примеры кода, приведенные в статье, легко добавить эту логику в свое приложение.
О подходе
Организацию ленивого бесконечного списка через Deferrable Views нельзя назвать идеальной во всех отношениях, в первую очередь из-за несовместимости со старыми версиями Angular. Но, если можно использовать Angular 17, он будет неплохой альтернативой ранее существовавшим подходам, так как дает возможность навести чистоту в коде, избавившись от сложных обвязок для работы с Intersection Observer API или более примитивными методами.
Отдельно стоит упомянуть совместимость подхода с использованием виртуального скролла, что открывает дополнительные возможности оптимизации работы с большими списками. В этой статье решил не рассматривать применение виртуализации именно с целью фокусировки на предлагаемом подходе, а не на описании кейсов применимости.
В комментариях можем обсудить особенности подхода и возникшие вопросы.