Всем привет! Меня зовут Павел Сапачёв, занимаюсь архитектурой и разработкой фронтенда в проекте «Тинькофф Лизинг». Мы любим создавать удобные, отзывчивые и производительные интерфейсы. Один из моментов улучшения — просмотр коллекций элементов. Самые популярные подходы к просмотру коллекций — постраничная разбивка и подгрузка при пролистывании страницы, которую называют бесконечными списками.

Если постраничная разбивка является классическим и понятным представлением навигации, то создание бесконечных списков содержит ряд подводных камней и технических сложностей.

В статье поделюсь реализацией списков на основе 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()
  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 или более примитивными методами.

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

В комментариях можем обсудить особенности подхода и возникшие вопросы.

Комментарии (0)