Оптимизация производительности длинных списков в AngularJS

в 10:19, , рубрики: AngularJS, javascript, optimization, performance

AnglarJS это здорово! Но при работе с большими списками, содержащими сложной структуры данных, он может начать работать очень медленно! Мы столкнулись с этой проблемой при переносе нашей административной панели на AngularJS. Она должна была работать без задержек при отображении около 500 строк. Но на первое отображение уходило до 7 секунд. Ужасно!
Мы обнаружили два узких места в нашей реализации. Одно было связано с директивой ng-repeat, а другое с применением фильтров.
Эта статья рассказывает о результатах наших опытов с различными подходами по решению, или смягчению, возникшей проблемы с производительностью. Это даст вам идеи и советы, куда вы можете приложить свои силы, а какие подходы все-таки не стоит использовать.

Почему директива ng-repeat медленно работает с большими списками?

Директива ng-repeat начинает работать медленно, если осуществляется двусторонняя привязка к спискам, имеющим более 2500 элементов. Вы может почитать об этом подробнее в посте Misko Hevery. Это объясняется тем, что в AngularJS отслеживаются изменения способом «грязной проверки». Каждое отслеживание изменений будет занимать некоторое время, что для больших списков со сложной структурой данных выливается в замедление работы вашего приложения.

Используемые предпосылки для анализа производительности

Отслеживание времени работы директивы:
Чтобы измерить время отображения списка нами была написана простая директива, которая измеряет продолжительность работы ng-repeat используя ее свойство $last. Базовая дата хранится в нашем сервисе TimeTracker, так что результат не зависит от загрузки данных с сервера.

// директива для ведения журналов времени отображения
angular.module('siApp.services').directive('postRepeatDirective', 
  ['$timeout', '$log',  'TimeTracker', 
  function($timeout, $log, TimeTracker) {
    return function(scope, element, attrs) {
      if (scope.$last){
         $timeout(function(){
             var timeFinishedLoadingList = TimeTracker.reviewListLoaded();
             var ref = new Date(timeFinishedLoadingList);
             var end = new Date();
             $log.debug("## DOM отобразился за: " + (end - ref) + " ms");
         });
       }
    };
  }
]);

Использование в HTML:

<tr ng-repeat="item in items" post-repeat-directive>…</tr>

Особенности отслеживания хронологии с помощью инструментов разработки в Chrome
На вкладке хронология (timeline) инструментов разработчика Chrome, вы можете увидеть события (events), количество кадров браузера в секунду (frames) и выделение памяти (memory). Инструмент memory полезен для выявления утечек памяти и для определения количества памяти, в которой нуждается ваше приложение. Мерцание страницы становится проблемой, когда частота обновления кадров будет меньше 30 кадров в секунду. Инструмент frames показывает информацию о производительности системы отображения страницы. Кроме этого в нем отображается сколько времени ЦП потребляет javascript.

Основные настройки, ограничивающие размер списка

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

Разбиение на страницы

Наш способ разбиения на страницы основан на комбинации фильтра AngularJS limitTo (начиная с версии 1.1.4) и нашего фильтра startFrom. Этот подход позволяет сократить время отображения путем ограничения размера отображаемого списка. Это самый эффективный способ сокращения времени отображения.

// Разбиение на страницы в контроллере
$scope.currentPage = 0; 
$scope.pageSize = 75;
$scope.setCurrentPage = function(currentPage) {
    $scope.currentPage = currentPage;
}

$scope.getNumberAsArray = function (num) {
    return new Array(num);
};

$scope.numberOfPages = function() {
    return Math.ceil($scope.displayedItemsList.length/ $scope.pageSize);
};

// наш фильтр startFrom
angular.module('app').filter('startFrom', function() {
    return function(input, start) {         
        return input.slice(start);
};

Использование в HTML.

<!-- кнопки навигации по страницам -->
<button ng-repeat="i in getNumberAsArray(numberOfPages()) track by $index" ng-click="setCurrentPage($index)">{{$index + 1}}</button

<!-- отображаемый список -->
<tr ng-repeat="item in displayedItemsList | startFrom: currentPage * pageSize  | limitTo:pageSize" /tr>

Если вы не хотите или не можете использовать разбиение на страницы, но вас все же волнует проблема медленной работы фильтров, не поленитесь посмотреть шаг 5, и используйте ng-hide, чтобы скрыть не нужные элементы списка.

Бесконечная прокрутка

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

Рекомендации по оптимизации

1. Отображайте список без привязок данных

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

2. Не используйте вызов встроенного метода для получения данных

Не используйте метод для получения отфильтрованной коллекции, чтобы получить отфильтрованный список непосредственно в контроллере. ng-repeat вычисляет все выражения на каждом цикле $digest, т.е. это делается очень часто. В нашем примере filteredItems() возвращает отфильтрованную коллекцию. Если он работает медленно, это быстро приведет к замедлению работы всего приложения.

<li ng-repeat="item in filteredItems()">  <!--Плохо, так как очень часто будет вычисляться.-->
<li ng-repeat="item in items"> <!-- пойдем по этому пути -->

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

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

/* Контроллер */
// Базовый список 
var items = [{name:"John", active:true }, {name:"Adam"}, {name:"Chris"}, {name:"Heather"}]; 

// инициализация отображаемого списка
$scope.displayedItems = items;

// Кэш фильтров
var filteredLists['active'] = $filter('filter)(items, {"active" : true});

// Применение фильтра
$scope.applyFilter = function(type) {
    if (filteredLists.hasOwnProperty(type){ // Проверка наличия фильтра в кэше
        $scope.displayedItems = filteredLists[type];
    } else { 
        /* Если фильтр не закэширован */
    }
}

// Сброс фильтров
$scope.resetFilter = function() {
    $scope.displayedItems = items;
}

В представлении:

<button ng-click="applyFilter('active')">Выбрать активные</button>
<ul><li ng-repeat="item in displayedItems">{{item.name}}<li></ul>
4. Используйте ng-if вместо ng-show для дополнения шаблонов

В случае если вы используете дополнительные директивы или шаблоны для отображения дополнительной информации по элементу списка, в случае клика по нему, используйте ng-if (с версии 1.1.5). ng-if запрещает отображение (в отличии от ng-show). В этом случае дополнительные элементы добавляются, а привязки разрешаются именно когда они нужны.

<li ng-repeat="item in items">
    <p> {{ item.title }} </p>
    <button ng-click="item.showDetails = !item.showDetails">Show details</buttons>
    <div ng-if="item.showDetails">
        {{item.details}}
    </div>
</li>

5. Не используйте такие директивы AngularJS, как ng-mouseenter, ng-mouseleave и т.д.

На наш взгляд, использование встроенной директивы AngularJS ng-mouseenter вызвало мерцание экрана. Частота кадров в браузере была в большинстве случаев ниже 30 кадров в секунду. Использование чистого jQuery для создания эффекта анимации и эффектов наведения поможет решить эту проблему. Не забудьте только обернуть события мыши функцией jQuery.live() — чтобы получать уведомления от элементов, добавляемых в DOM позже.

6. Настройка свойств для фильтрации. Скрытие исключенных элементов с помощью ng-show

С длинными списками фильтры так же работают медленнее, так как каждый фильтр создает собственное подмножество первоначального списка. Во многих случаях, когда первоначальные данные не меняются, результат применения фильтра остается тем же. Чтобы это использовать, можно предварительно отфильтровать список данных, и применять результат фильтрации в момент когда это будет нужно, экономя на времени обработки.
При применении фильтров с директивой ng-repeat, каждый фильтр возвращает подмножество оригинальной коллекции. AngularJS также удаляет исключенные фильтром элементы из DOM, и возбуждает событие $destroy, удаляющее их и из $scope. Когда входная коллекция изменяется, подмножество элементов прошедших через фильтр также изменяется, что вновь вызывает их перерисовку или разрушение.
В большинстве случаев такое поведение является нормальным, но в случае когда пользователь часто использует фильтрацию или список очень большой, непрерывная перелинковка и разрушение элементов сильно влияет на производительность. Чтобы ускорить фильтрацию можно использовать директивы ng-show и ng-hide. Вычисляйте фильтры в контроллере и добавьте свойство для каждого элемента. Используйте ng-show со значением этого свойства. В результате этого директивой ng-hide просто будет добавляться определенный класс, вместо удаления элементов из подмножества первоначальной коллекции, $scope и DOM.

  • Первый способ вызова ng-show, это использования синтаксиса выражений. Выражение ng-show вычисляется с помощью встроенного синтаксиса фильтра.
    Смотрите также следующий пример на plunkr

    <input ng-model="query"></input>
    <li ng-repeat="item in items" ng-show="([item.name] | filter:query).length">{{item.name}}</li>
    

  • Другой способ — это передача определенного значения через атрибут в ng-show, и дальнейшие вычисления, исходя из переданного значения, в отдельном подконтроллере. Этот способ является несколько более сложным, но его применение является более чистым, что Бен Надель обосновывает в статье своего блога.

7. Настройка подсказок для фильтрации: пересылка входных данных

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

/* Контроллер*/
// Отслеживание ввода и пересылка в систему фильтрации каждые 350 мс.
$scope.$watch('queryInput', function(newValue, oldValue) {
    if (newValue === oldValue) { return; }
    $debounce(applyQuery, 350);
});
var applyQuery = function() { 
    $scope.filter.query = $scope.query;
};

/* Представление*/
<input ng-model="queryInput"/>
<li ng-repeat= item in items | filter:filter.query>{{ item.title }} </li>

Для дальнейшего чтения

  1. Организация проекта для огромных приложений
  2. Ответ Misko Hevery на StackOverflow на вопрос относительно производительности привязок данных в Angular
  3. Короткая статья о различных способах повышения производительности ng-repeat
  4. Загрузка больших данных по запросу
  5. Хорошая статья об области видимости
  6. AngularJS проект для динамических шаблонов
  7. Отображение без привязок данных

Автор: Tulov_Alex

Источник

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


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