by Zdravko Verguilov, ServiceNow Platform Developer, Do IT Wise
Infinite scroll looks good and adds an overall level of smoothness to a page. But it’s not just about looks. AngularJS is slow, and every little bit of performance counts. In this article, we’ll see two types of infinite scrolling – a ‘fake’ one, which has little impact on performance and is mainly a visual gimmick, and ‘the real deal’, which can be an actual gamechanger for slow pages dealing with lots of data.
We have two basic processes that can severely affect the performance of a page – data retrieval and rendering. For example, let’s imagine a simple table widget representing a large number of incidents or some other type of record. Let’s say we have 100 of those, and we don’t really like old school pagination. If we traditionally approach this/it, we’ll get them all with a GlideRecord query from the server script. This alone could take some seconds of loading time.
Modern browsers are optimized to handle a DOM tree of about 1500 HTML elements. When we visualize some basic information for our records as columns in this table, we can easily have about 20-30 HTML elements on every row, which instantly results in twice the optimal number for the entire page. And that’s one widget only. What if we need to fit a couple more, even if they’re simple ones?
Drawing all this takes time. Every single box will then have its own CSS properties to be read and applied. Some logic will need to run on the Client Script, some events will trigger something else down the line, and what we get in the end is a giant clutter that takes seconds to respond to any kind of user input. And 20+ years into the 21st century, that is simply unacceptable.
When set up correctly, the infinite scroll can tackle both issues at the same time. It can be used to trigger several small queries in crucial moments, avoiding the big slow one. It will also draw the table containing one data batch at a time, thus cutting down rendering time.
Let’s see what an oversimplified version of such a widget would look like. Before we go to the more complex stuff, we need our basic HTML structure:
<div>
<div>
<table>
<thead>
<th scope="col" colspan="1">
Category
</th>
<th scope="col" colspan="1">
Number
</th>
<th class="description">
Short Description
</th>
<th scope="col" colspan="1" class="mid">
Priority
</th>
<th scope="col" colspan="1" class="mid">
State
</th>
</thead>
<tbody>
<tr ng-repeat="incident in c.data.incidents | limitTo: displayNumber">
<td ng-bind-html="::incident.category"></td>
<td ng-bind-html="::incident.number"></td>
<td ng-bind-html="::incident.short_description"></td>
<td ng-bind-html="::incident.priority" class="mid"></td>
<td ng-bind-html="::incident.state" class="mid"></td>
</tr>
</tbody>
</table>
</div>
</div>
Take a good look at the ng-repeat directive. That’s where the $scope.displayNumber property comes into action. Every time we increment it, more rows will be generated in the table. So, if we only need a visual infinite scroll, we can easily set up a server script query, get all the data at once, implement the triggering logic below and call it a day. Now let’s continue with the actual solution.
Another important detail here is the use of ng-bind-HTML directive instead of ‘{{value}}’ type of interpolation. This affects performance as well, so maybe it’s a good idea to stick to it whenever possible.
In order to have a properly working infinite scroll, we need a solid triggering logic. It has to engage every time we scroll down to a certain level, but it also shouldn’t be too sensitive, otherwise, we might end up sending tens of backend queries for a few millimeters of a scroll. Here’s one way to set up the Client Script. As we know, there’s no ‘right way’ to do stuff in JavaScript, only different levels of wrong:
function($scope, $window, $rootScope, $http) {
var c = this;
var emnt = document.getElementsByTagName('section')[0];
$scope.loadPoint = false;
$(emnt).on('scroll', function() {
if(emnt.scrollHeight - emnt.scrollTop < 1200) {
$scope.loadPoint = $scope.loadPoint ? $scope.loadPoint : true;
}
});
$scope.displayNumber = 20;
$scope.$watch('loadPoint', function() {
if($scope.loadPoint) {
$scope.displayNumber += 20;
setTimeout(function() {
$scope.loadPoint = false;
}, 1000)
}
})
}
Time to elaborate:
- We need the height of the page, therefore we assign the main HTML element, a ‘section’, to a variable;
- We define the loadPoint property of the $scope object, which is a state object, accessible in some form by all widget elements – HTML template, Client, and Server scripts. We’ll use this property as a trigger later;
- We set up a watcher, waiting for a ‘scroll’ event. Whenever it detects a scroll, it evaluates the difference between the page height, taken by the height of the ‘section’, and the scrollTop value, which tells us how far from the top we scrolled. If that difference gets below a certain point, i.e. we’ve scrolled down far enough, the loadPoint indicator gets activated, but only if it isn’t already active;
- We set the initial number of displayed records, which we use in an ng-repeat to create the visual part of the infinite scroll;
- We set up a watcher that detects changes in the loadPoint flag. If changed to true, we increase the displayed records number, and set the loadPoint back to false. There’s a one second delay for that action, so it wouldn’t activate again too soon, thus flooding with backend requests. Once we build our backend query function, here’s where we’ll call it.
This flag/delay combination ensures a smooth and predictable behavior, while also significantly reducing the number of unnecessary queries.
We have the HTML, and we have a trigger that can fire a backend call in a controlled manner every time we scroll close to the bottom of the page. So, let’s set up the actual call. We won’t use Server script for this one, as in cases like this, the Table API gives us an unparalleled level of control and fine-tuning:
c.getIncidents = function (num) {
var params = ['sysparm_fields=category,number,short_description,priority,state',
'sysparm_offset=' + index * 20,
'sysparm_limit=20'
].join('&');
$http.get('/api/now/table/incident?' + params)
.then(function(res) {
var data = res.data.result.slice();
c.data.incidents = c.data.incidents ? c.data.incidents.concat(data) : data;
index++;
})
}
The ‘params’ array is where we define all the necessary aspects of our HTTP call. In this case, we define the actual field values we want to receive so that we wouldn’t move the whole record around. A lighter call is a faster call. The offset parameter lets us take a specific batch of records. If we already have the first 20 or 40, we’d want to get the next batch of 20, not to start over. The limit defines how big our batches will be.
We make a simple ‘GET’ call to the Incident table, then process the response. If we have some data already, we keep it and add the new one to it. Incrementing the ‘index’ variable, in the end, is important, as it is used in the offset parameter to get the right batch of data next time around. Now here’s the whole Client Script:
function($scope, $window, $rootScope, $http) {
var c = this;
$scope.displayNumber = 20;
var index = 0
c.getIncidents = function (num) {
var params = ['sysparm_fields=category,number,short_description,priority,state',
'sysparm_offset=' + index * 20,
'sysparm_limit=20'
].join('&');
$http.get('/api/now/table/incident?' + params)
.then(function(res) {
var data = res.data.result.slice();
c.data.incidents = c.data.incidents ? c.data.incidents.concat(data) : data;
index++;
})
}
c.getIncidents(0);
$scope.$watch('loadPoint', function() {
if($scope.loadPoint) {
$scope.displayNumber += 20;
c.getIncidents($scope.displayNumber);
setTimeout(function() {
$scope.loadPoint = false;
}, 1000)
}
})
var emnt = document.getElementsByTagName('section')[0];
$scope.loadPoint = false;
$(emnt).on('scroll', function() {
if(emnt.scrollHeight - emnt.scrollTop < 1200) {
$scope.loadPoint = $scope.loadPoint ? $scope.loadPoint : true;
}
});
}
We add CSS code to make things look adequate:
th {
min-width: 12rem;
margin: 2rem;
}
td {
padding-top: 2rem;
}
.description {
min-width: 20rem;
}
.mid {
text-align: center;
}
And here’s the result we want. Pay attention to the scrollbar and you’ll catch the exact moment we get our new batch of data:
Portent research states that the first five seconds of page-load time have the highest impact on conversion rates. So, optimizing the speed performance of a website and the overall user experience is something we should always strive for. Share with us your tips for improvement in the comment section.
Check out our post about adding a “Language Picker” in the ServiceNow Portal Header.
If you find this article useful, share it with your colleagues.