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:
<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:
.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:
<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:
sens
- this argument is opposite to sensitivity of scrolling, so 1
means very big sensitivity. limit
- size of portion, limit
part of limitTo
height
- simple corresponding HTML style
attribute for root directive length
- length of array
(is watched) 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
<!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>
<!--
<!--
<style>
.smart-scroll {
width:15px;
}
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
.smart-scroll {
position: relative;
width:auto;
}
}
</style>
<!--
<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> </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
(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() {
});
})(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