Как стать автором
Поиск
Написать публикацию
Обновить
377.98

Ленивые бесконечные списки на основе Deferrable Views

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров4.1K

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

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

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

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

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

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+16
Комментарии1

Публикации

Информация

Сайт
l.tbank.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия