Бесконечная прокрутка в веб-приложениях с примерами на AngularJS

в 17:24, , рубрики: AngularJS, javascript, архитектура приложений, Веб-разработка, метки: ,

Мишко Хевери, главный разработчик Ангуляра, как-то упомянул, что приложение гарантированно работает без тормозов, если в нем не более 100 активных областей видимости. Такой подход, в общем, применим к любым приложениям. В играх давно не рендерят то, чего игрок не видит и только в вебе пока еще считается нормой отобразить целиком список из нескольких тысяч элементов. С приходом js-фреймворков ситуация должна измениться и лучшим решением станет удаление из DOM того чего нет на экране, нежели отказ от промежуточных тегов, биндингов и других вещей, облегчающих разработку. Поэтому провел небольшой анализ решений для отображения больших списков. Наткнулся на пару статей:

1. The Infinite Path of Scrolling

В ней парень рассказывает, что проходил стажировку в Гугле в команде Ангуляра и ему поручили исследовать этот вопрос. (Радует, что разработчики заинтересованы этим. Надеюсь, скоро увидим родную поддержку бесконечного скролла).

Существуют два решения: старая добрая пагинация и бесконечная прокрутка. Причем, бесконечная прокрутка может как подгружать необходимые данные, так и удалять то что уже просмотрено, что реализовано, например, в компоненте UITableView из IOS. Автор попытался воссоздать этот компонент для Ангуляра.

image

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

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

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

image

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

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

Пользователь может прокручивать назад (вверх) и то же происходит в обратном порядке. Элементы, которые не видны в конце помещают в пул. Другие элементы берутся из хранилища данных и помещаются до первого видимого элемента, а удаленные элементы используются повторно, когда мы получаем данные.

Удаление элементов сразу же как они выходят из области просмотра не самая удачная идея, т.к. придется опять их запрашивать, если пользователь прокрутит на несколько элементов в обратном направлении. Есть смысл класть недавно загруженные элементы в кэш и лишь очень старые опять запрашивать с сервера.

image

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

Минимальный HTML, необходимый для скроллера:

<div ng-scroller>
  <div ng-scroller-repeat=”post in posts”>
    <div>{{post.author}}: {{post.text}}</div>
  </div>
</div>

Элемент с ng-scroller — область просмотра, элемент с ng-scroller-repeat — контейнер (синяя область на диаграмме выше) и внутренний элемент шаблона, который будет продублирован и связан с дочерней областью видимости, созданной для каждого элемента.

Ключ posts в области видимости, в этом случае, должен указывать на объект хранилища данных, который реализует следующий интерфейс для запроса элементов. Если ключ указывает на массив, для удобства он получает обернутый объект, который реализует требуемый интерфейс.

IScrollerDataStore {
  void getRangeAfter(prev_id, length, function(Error, Array));
  void getRangeBefore(next_id, length, function(Error, Array));
}

Если необходимо отобразить нескольких шаблонов, то следует использовать директивы ng-if и ng-switch. В большинстве случаев этого достаточно.

<div ng-scroller>
  <div ng-scroller-repeat=”post in posts”>
    <div ng-class=”{ link: post.link, photo: post.photo }”>
      {{post.author}}:
      <a ng-if="post.link" href=”{{post.link}}”>{{post.link}}</a>
      <img ng-if="post.photo" ng-src=”{{post.photo}}”>
    </div>
  </div>

Первоначально он хотел разрешить разработчикам расширять скроллер, но решил не усложнять людям жизнь и реализовать основные случаи самостоятельно. Например, распространенный случай случай с кнопкой «Загрузить еще» и т.п.

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

Первые два типа можно сделать на чистом CSS. Последний сложнее, т.к. липкость динамически изменяется во время прокрутки.

Когда он обсуждал скроллер с таким же разработчиком, который работает над аналогичным компонентом внутри Гугла, он посоветовал сосредоточиться на работе с состоянием удаленных элементов, которые придется восстановить позже. Пример с твитами: вы можете нажать на твит и раскроете весь разговор. Что делать, если, прокрутить открытый твит вниз, чтобы тот удалился из DOM, а затем прокрутить назад. Нужно будет восстановить его состояние.

С этим можно справиться двумя способами. Либо хранить состояние в области видимости элемента, созданного с помощью ng-scroller-repeat (т.е. tweet.open = true) или как-то отслеживать изменения в самой области видимости, создав таблицу соответствий с отдельными элементами и восстанавливать их если элемент используется снова.

На данный момент он выбрал первый подход, как наиболее простой.

Проект на Гитхабе, демки
Проект, очевидно, не доработан, но идеи, заложенные в нем, вполне здравые.

2. AngularJS Virtual Scrolling. Часть 1, часть 2

Здесь человек переписал директиву ng-repeat, которую обозвал sf-repeat. Разбирает задачу на примере журнала логов.

<div style="overflow: scroll; height:200px;">
  <ol>
   <li ng-repeat="event in eventLog">{{event.time}}: {{event.message}}
  </ol>
</div>

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

Пытается решить проблему фильтром

angular.module('sf.virtualScroll').filter('sublist', function(){
  return function(input, range, start){
    return input.slice(start, start+range);
  };
});
<div style="overflow: scroll; height:200px;">
  <ol>
   <li ng-repeat="event in eventLog|sublist:rows:offset">{{event.time}}: {{event.message}}</li>
  </ol>
</div>

Передача выражения в директиву разделяется на две части: идентификатор значения event и идентификатор коллекции eventLog|sublist:rows:offset. Идентификатор коллекции вычисляется каждый раз во время грязной проверки и сравнивается с предыдущим значением. Таким образом высчитывается только видимый диапазон. Если коллекция изменилась экран обновляется и если значение в области видимости изменилось, видимая позиция списка меняется.

Осталось дать пользователю возможность изменять положение прокрутки.

Чтобы диапазон полосы прокрутки, прикрепленной к контейнеру (т.е. DIV с overflow: scroll) не изменялся, нужно обмануть браузер, добавив область с пустым контентом. Изменяя высоту пустого контента, мы контролируем диапазон прокрутки. Проблема в том, как получить высоту контента.

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

Делает свой виджет прокрутки:

<div style="overflow: scroll; height:200px;">
  <div sf-scroller="y = 0 to eventLog.length" ng-model="slicePosition"></div>
</div>

Разбор выражения диапазона (y = 0 to eventLog.length)

function parseRangeExpression (expression) {
  var match = expression.match(/^(x|y)s*(=|in)s*(.+) to (.+)$/);
  if( !match ){
    // throw an informative Error.
  }
  return { axis: match[1], lower: match[3], upper: match[4] };
}

Директива

var mod = angular.module('sf.virtualScroll');
mod.directive("sfScroller", function(){
  //function parseRangeExpression ...
  return function(scope, element, attrs){
    // ...
  };
});

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

Решает переделать ng-repeat, разбирает её.

Первое, что следует отметить, использование в директиве transclude и функции компиляции. Более простые директивы используют только связующую функцию, но ng-repeat нуждается в доступе связующей функции, которая свяжет скомпилированные элементы с новой областью видимости для каждого элемента в коллекции (подробнее в разделе «Причины разделения стадии компиляции и связывания» руководства разработчика).

По сути, связующая функция парсит выражение в ng-repeat и устанавливает watch-наблюдатели. Наблюдатели нужны для добавления и удаления элементов и синхронизации с коллекцией, но нужно быть внимательным при отслеживании перемещений элементов. Если у вас есть элемент, соответствующий элементу в коллекции, и он имеет некоторое состояние в DOM (хорошим примером является элемент формы), то вы не хотите, чтобы элемент удалялся и создавался повторно только потому, что базовый объект переместился в коллекции. Это не тривиальная задача, но после того как мы позаботились обо всех перестановках, код для добавления новых и удаления существующих элементов становится относительно простым.

Решает не сохранять состояние DOM элемента при удалении, т.к. состояние должно храниться в модели (предыдущий автор делал так же)

Другая тонкость ng-repeat в том, что коллекция может быть объектом и элементы будут показаны с использованием for(key in collection). Т.к. из-за этого возникают проблемы с индексами и расположением элементов, решает обойтись только массивами.

Описывает свою sf-repeat, говорит, что элементы должны приходить с сервера фиксированными порциями.

image

Объясняет, что нужно определять отметки после и перед которыми добавляются или удаляются элементы. Чтобы вычислить отметки нужно зать высоту строки. С этим опять проблемы, т.к. в фазе компиляции/сборки нельзя определить, что элемент отрендерен полностью. Решает вычислять высоту из CSS (явную или максимальную).

Высоту окна просмотра берет так же из CSS.

Директива

var mod = angular.module('sf.virtualScroll');
mod.directive("sfVirtualRepeat", function(){
  return {
    transclude: 'element',
    priority: 1000,
    terminal: true,
    compile: sfVirtualRepeatCompile
  };
  // ...
});

Функция компиляции

function sfVirtualRepeatCompile(element, attr, linker) {
  var ident = parseRepeatExpression(attr.sfVirtualRepeat),
      LOW_WATER = 100,
      HIGH_WATER = 200;
 
  return {
    post: sfVirtualRepeatPostLink
  };
  // ...
}

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

Алгоритм наблюдателя может быть сведен к сравнению нового активного диапазона с существующим и решения, какие строки уничтожить, а какие создать. Расположение элементов на определенном месте можно сделать абсолютным позиционированием или отступом у первого элемента. Автор сделал с помощью отступа.

Директива sf-virtual-repeat является частью модуля sf.virtualScroll на Гитхабе. Исходник, bower-компонент, демка.

Основная проблема этого решения в том, что необходимо использовать CSS.


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

Автор: tamtakoe

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js