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

  • Tutorial
Мишко Хевери, главный разработчик Ангуляра, как-то упомянул, что приложение гарантированно работает без тормозов, если в нем не более 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.



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

P.S. Исследование разработчиков LinkedIn
P.P.S. Вопрос на Тостере. Есть интересные ответы
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 24

    +23
    Решение проблемы бесконечной прокрутки — не делать бесконечную прокрутку.
      0
      Не всегда это возможно. Если у тебя лента твитов, сообщений и т.п.
        +1
        Всегда возможно сделать при прокрутке вниз кнопку — Показать еще.
        А вообще, хуже бесконечной прокрутки может быть только бесконечная прокрутка вверх, как например в чате в Фейсбуке (с точки зрения реализации).
          +2
          Это тоже бесконечная прокрутка. При 10 нажатиях «показать еще» у тебя на странице столько областей видимости и биндингов будет, что начнутся тормоза. Проблема в том, чтобы удалять старые данные.
            +3
            зачем на список твитов биндинги данных? они фактически не изменяются, и более эффективно не делать так, потому что это просто бессмысленно. да и вообще это очень часто не нужно, зря везде все так делать начали, зря зря зря.
              0
              В AngularJS разве можно иначе? Получили модель, одели ее на вьюшку. Байндинги — это просто следствие.
                +1
                  0
                  Буду знать, спасибо. Интересный подход, хоть и хак ангулара.
                0
                Кнопку показывать, количество лайков и ретвитов динамически менять. Можно, конечно, и без них, сделать костыль и через всплытие событий все разруливать и ng-repeat переписать, чтобы не создавала обособленные области видимости, но тем самым мы лишаем себя всех ангуляровских удобств, а это глупо
                  0
                  глупо бессмысленно тратить ресурсы. а организовывать трансялцию всех событий в реал тайме — безумно дорого, а не в реалтайме это несколько теряет смысл и так никто не делает.
                    0
                    А как делают?
                0
                На что биндинги? На кнопку показать еще?
                  0
                  Хотя бы и кнопку показывать
                    0
                    А зачем кнопку показывать? Почему ее просто не отрендрить в конце? Единственный биндинг — на клик по кнопке, чтобы на ее месте отобразить процесс загрузки, а потом и вовсе убрать
                      0
                      Имел в виду кнопки ретвитов и т.п. Плюс каждое выражение {{twitt.author}} {{twitt.title}} {{twitt.text}} вотчится в области видимости. В итоге, при большом списке цикл грязной проверки будет проверять тысячи элементов, что не быстро.
                0
                Зависит от цели. Если ваша цель показать как можно больше «записей», то тогда бесконечная прокрутка — просто подарок. Во всех отношениях.

                А если у вас проблемы с выдачей большого количества «записей» — тогда да, приходится «затруднять» навигацию всякими кнопочками и линками.
                +3
                Вы не поверите, есть такая штука, о ней мало людей знает, говорят ей пользовались наши мудрые предки. Называется постраничной выдачей, можете себе представить? И что делает ленту твитов такой уникальной, что ее по страницам нельзя распихать?
                +2
                Угу, еще одна история о том, как web-страница хочет быть приложением, на этот раз — с некой своеобразной эмуляцией memory management. Проблема только в том, что действительно можно написать все так, что объекты DOM будут удаляться, но это не гарантирует того, что настоящая память тоже будет освобождена (да, это вопрос и к разработчикам browser-ов, но обязаны ли они были такое предусматривать?).

                Например, у меня есть нетбук c гигабайтом оперативки и отключенным page file для тестов — на нем уронить firefox с ошибкой «недостаточно памяти» бесконечным скроллом — только так, достаточно страницы с «кумулятивным объемом» всего 60-70Мб (с картинками).

                Я уже не говорю о том, что все эти бесконечные асинхронные загрузки, которые не отражаются в URL (а кто об этом беспокоится?) рушат сами базовые концепции web непонятно ради чего. Лет 10 назад получить от кого-нибудь ссылку на корень сайта с объяснениям «там надо еще нажать туда и туда...» было редкостью, сейчас все уже привыкли, что из адресной строки нет смысла копировать.
                  0
                  Думал об этом. Мне нравится идея из первой статьи где парень оперирует страницами. Можно и хеш в адресной строке менять при скролле, тогда и с ссылками проблем не будет.

                  Тут смысл не столько в экономии памяти (хотя и в этом тоже), сколько в уходе от лишних расчетов для того, чего не видно.
                  • UFO just landed and posted this here
                      +2
                      В первом случае это будет «брать из начала очереди и переставлять в конец»? Тогда это еще более адское вмешательство в разметку…

                      Best practice с hash в URL есть, кое-кто это использует, но это все равно ситуация из разряда «оторвать ногу, чтобы ее потом пришить». При том на сколько продуктивны все эти вещи и сколько ругательств от обычных пользователей это порождает — достоверно неизвестно. По моим многолетним ощущениям, best practice — это «если можно не использовать скрипты — не используй их».
                      • UFO just landed and posted this here
                          0
                          По поводу последнего вопроса — я, на самом деле, жду, когда кто-нибудь из разработчиков browser-ов вспомнит про XLink вообще и конкретно — xlink:show=«embed», и большая часть ajax-костылей на JS перестанут иметь смысл, потому что все эти фокусы будут поддерживаться browser-ами нативно. :) Вон тот же JQuery mobile это в явном виде эмулирует…
                          А пока эти вещи приходится заменять чем-то, вот я и интересуюсь, что же еще придумали «от бедности».
                      +1
                      Видимо пора новой технологии не основанной на DOM и html. С одной стороны веб приложения (и всякие single-page) вещь нужная с другой стороны, строя эти приложения по верх старых технологий получаем химеру.

                  Only users with full accounts can post comments. Log in, please.