Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

ng-repeat Performance Degradation at Case of Very Big Iterated Array

5.00/5 (5 votes)
13 Sep 2017CPOL4 min read 33K  
How to solve problem, when ng-repeat directive iterates very big array and it causes significant performance degradation

Problem

Probably, you may have had a situation when you used ng-repeat directive to iterate a very big array with the length of more than 2k or 3k records with very complicated presentation logic of each item, I mean you may show its several properties, change style, show or hide some HTML tags and so on, depending on some conditions. In this case, you can face performance degradation problem, because AngularJS creates a lot of watchers with total number proportional to the array.length.

Solution

First of all, we can say that if you have more than 2k records, you probably won't show all of them together on screen, you will use some scrolling, for example infinite scrolling or usual one. Let's consider the last situation. In this case, we can tell AngularJS to show only certain portion(window) of all records, so instead of thousands of rows that should be processed and rendered, we will have strictly defined constant quantity, that means the number of watchers also will be limited and it will solve our performance problem.

Fortunately, AngularJS supplies us with feature limitTo: https://docs.angularjs.org/api/ng/filter/limitTo. With its help, we can specify what portion of array will be shown at current moment of time:

HTML
<code>{{ limitTo_expression | limitTo : limit : begin}}</code>

Where limit - size of portion and begin - from which array's index portion will be started. So all that we should do - to change begin parameter according to scroll position. For this task, we will create smartScroll directive:

HTML
.directive("smartScroll", function() {
    return {
      restrict: 'E',
      scope: {
        to: '=',
        length: '@'
      },
      template: `
                <div class="smart-scroll" style="overflow:auto;">
                  <div></div>
                </div>
            `,
      link: function(scope, element, attrs) {
        //set height of root div
        var root = angular.element(element.find('div')[0]);
        root.css('height', attrs.height);

        //scrolling over table also will work
        if (attrs.baseid) {
            var baseEl = document.getElementById(attrs.baseid);
            var handler = function(event) {
                element[0].firstElementChild.scrollTop += event.deltaY;
                event.preventDefault();
            }

            baseEl.addEventListener("wheel", handler);

            scope.$on('$destroy', function () {
                baseEl.removeEventListener("wheel", handler);
            });
        }

        scope.$watch('length', function() {
          //when array.length is changed we will change height of inner div 
          //to correct scrolling presentation of parent div accordingly
          var height = (scope.length - attrs.limit) * attrs.sens + attrs.height * 1;
          angular.element(element.find('div')[1]).css('height', height);

          //if we won't need scrolling anymore, we can hide it 
          //and shift scrolling to initial top position
          if (scope.length <= attrs.limit) {
            root[0].scrollTop = 0;
            root.css('display', 'none');
            scope.to = 0;
          } else
            root.css('display', 'block');
        });

        //when we perform scrolling, we should correct "to" argument accordingly
        root.on('scroll', function(event) {
          var scrolled = root[0].scrollTop;
          scope.$apply(function() {
            scope.to = scrolled / attrs.sens;
          });
        });
      }
    };
  });

HTML usage:

HTML
<tbody id='tableBody'>
    <tr ng-repeat="item in vm.array | limitTo : 10 : vm.to">   
       <td>{{item.name}}</td> 
       <td>{{item.age}}</td> 
    </tr> 
</tbody>

<smart-scroll sens='10' limit='10' height='400' length='{{vm.array.length}}' 
                        to='vm.to' baseid='tableBody'>
</smart-scroll>

Let's consider parameters:

  1. sens - this argument is opposite to sensitivity of scrolling, so 1 means very big sensitivity.
  2. limit - size of portion, limit part of limitTo
  3. height - simple corresponding HTML style attribute for root directive
  4. length - length of array (is watched)
  5. to - current position, from which portion is started (is changed), begin part of limitTo

Directive consists of two div tags: one with the height specified as parameter and with scrolling capability and second which is located inside former. Scroll position of root div will have two way data binding with to parameter: the greater the scroll pulls down the more to parameter should become and vice versa. Scrolling range depends on only height of inner div, because corresponding property of root div is a constant value. Height of inner div should be proportional to the (array.length - limit) i.e., (scope.length - attrs.limit), because we should be able to show all records, also, we provide very important and strictly accordance: sens * 1 pixelOfScrolling = 1 record, so to = ScrollPosition / (sens * 1 pixelOfScrolling) i.e., scope.to = scrolled / attrs.sens.

So height of inner div regulates scrolling range, which is proportional to the array.length and scroll position of root div connected with to parameter, i.e., what portion of records (from what index of array) should be shown.

We still have one problem: each time we intend to scroll mouse over table, nothing will happen, because our custom scrolling is not a native part of table. To fix it, we will pass id of data container (table or tbody tag) via baseid attribute to our directive, then add event listener on wheel event on this element and transpile it to custom scrollling and prevent former.

I created a sample with all of this code: https://plnkr.co/edit/TuRTIpplK4eLus2NVd4D. In this example, I also add filter on array, so you can show not only some portion of the original array, but also from filtered one with some conditions. It can be very comfortable to combine them together. Also, you should tune some CSS, to make directive work properly in all browsers.

I will reproduce this example.

HTML

HTML
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <script src="https://code.jquery.com/jquery-3.1.1.min.js" 
   integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous">
  </script>
  <link rel="stylesheet" 
   href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" 
   integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" 
   crossorigin="anonymous">
  <script src="//code.angularjs.org/snapshot/angular.min.js"></script>
  <script src="app.js"></script>

    <!-- [if IE] >
        <style>        
            .smart-scroll {                 
                position: relative;
            }        
        </style>
    <![endif]-->
    <!--[if !IE]><!-->
        <style>
            .smart-scroll {                
                width:15px;
            }
            @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
                .smart-scroll {
                    position: relative;
                    width:auto;
                }
            }
        </style>
    <!--<![endif]-->    
    <style type="text/css">
        @-moz-document url-prefix() {
            .smart-scroll {
                position: relative;
                width: auto;
            }
        }
    </style>

</head>

<body ng-app="app">
  <div ng-controller="MyController as vm">

    <div class="row">
      <div class="col-sm-8">
        <div class="form-group">
          <label>Search</label>
          <input class="form-control" ng-model="vm.search" />
        </div>
      </div>
      <div class="col-sm-2">
        <div class="form-group">
          <label>&nbsp;</label>
          <button type="button" class="btn btn-success col-sm-12" 
           ng-click="vm.add()">Add to Top</button>
        </div>
      </div>
    </div>

    <div class='row'>
      <div class="col-sm-10">
        <table class="table table-bordered" style="margin-bottom:0">
          <thead>
            <tr>
              <th>Name</th>
              <th>Age</th>
              <th></th>
            </tr>
          </thead>
          <tbody ng-init="vm.to=0" id='tableBody'>
            <tr ng-repeat="item in vm.getItems() | limitTo : 10 : vm.to" 
             ng-style='{"background-color": item.add ? "#ccffcc" : "white"}'>
              <td>{{item.name}}</td>
              <td>{{item.age}}</td>
              <td>
                <a ng-click="vm.removeItem(item)" href='#'>X</a>
              </td>
            </tr>
          </tbody>
          <tr>
            <th>Total:</th>
            <th colspan='2'>{{vm.quantity}}</th>
          </tr>
        </table>
      </div>
      <div class="col-sm-1" style="margin-top:0;padding-left: 0">
        <smart-scroll sens='10' limit='10' height='400' length='{{vm.quantity}}' 
                                to='vm.to' baseid='tableBody'></smart-scroll>
      </div>
    </div>

  </div>
</body>

</html

JavaScript

JavaScript
(function(angular) {
  'use strict';
  var myApp = angular.module('app', []);

  myApp.controller('MyController', ['$scope', '$filter', function($scope, $filter) {
    var self = this;

    self.filter = $filter('filter');
    self.items = [];
    self.quantity = 50000;
    for (var i = 0; i < self.quantity; i++)
      self.items.push({
        name: 'Name' + i,
        age: i + 1
      });

    self.getItems = function() {
      var out = self.filter(self.items, {name : self.search});
      self.quantity = out.length;
      return out;
    };

    self.removeItem = function(item) {
      self.items.splice(self.items.indexOf(item), 1);
    };

    self.add = function() {
      self.items.unshift({
        name: 'Name' + self.items.length,
        age: i + self.items.length,
        add: true
      });
    };

  }]).directive("smartScroll", function() {
     //directive's code is already presented above
  });
})(window.angular)

Conclusion

In this article, I showed how to solve the problem with performance degradation in the case of usage of ng-repeat with very big iterated array. The solution is based on the idea to show and render only visible for user portion of this array instead of whole one. This goal can be archived by using simple scrolling with AngularJS feature: limitTo and implemented as custom directive, where position of scrolling is reflected to array's index which is a top border of shown portion (window).

History

  • 13th September, 2017: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)