Saturday, 23 February 2019

AngularJS Performance in Large Applications

1 Introduction

Whether you are writing an Angular front end for an old application with large use and adoption, or your pre-existing Angular application is gaining momentum, performance is an important aspect. It is important to understand what causes an AngularJS application to slow down, and to be aware of tradeoffs that are made in the development process. This article will walk through some of the more common performance problems caused by AngularJS as well as given suggestions on how to fix and avoid them in the future.

1.1 Requirements, Assumptions

This article is going to assume some familiarity with the JavaScript programming language and AngularJS. When version-specific features are used, they will be called out as such. To really get the most out of this article, it would be best if you had spent some time playing with Angular, but had not yet seriously tackled performance.

2 Tools of the Trade

2.1 Benchmarking

A fantastic tool for benchmarking one's code is jsPerf. I will link to specific test runs at the end of relevant sections for readability.

2.2 Profiling

The Chrome Dev Tools have a fantastic Javascript profiler. I highly recommend reading this series of articles.

2.3 Angular Batarang

A dedicated Angular debugger is maintained by the Angular Core Team and available on GitHub.

3 Software Performance

There are two fundamental causes of non-performant software.
The first is algorithmic time complexity. Fixing this problem is largely outside the scope of this article, suffice it to say that in general time complexity is a measure of how many comparisons a program needs to make to achieve a result. The larger the number of comparisons, the slower the program. A simple example is linear search vs. binary search. Linear search needs to make more comparisons for the same set of data, and will therefore be slower. For a detailed discussion of time complexity, refer to the Wikipedia article.
The second reason software is slow is known as space complexity. This is a measure of how much 'space' or memory a computer needs to run your solution. The more memory required, the slower the solution. Most of the problems this article will talk to fall loosely under space complexity. For a detailed discussion, see here.
##4 Javascript performance
There are several things to be said about performant Javascript that are not necessarily limited to Angular.
###4.1 Loops
Try and avoid making calls in a loop. Any call that can be done once outside a loop will dramatically speed up your system. For example:
var sum = 0;
for(var x = 0; x < 100; x++){
  var keys = Object.keys(obj);
  sum = sum + keys[x];
}
Will be dramatically slower than:
var sum = 0;
var keys = Object.keys(obj);
for(var x = 0; x < 100; x++){
  sum = sum + keys[x];
}
###4.2 Dom access
It is important to note that accessing the DOM...
angular.element('div.elementClass')
...is expensive. While this should rarely be a problem in AngularJS, it is still useful to be aware of this. The second thing to say here, is that when possible, the DOM tree should be kept small.
Finally, if at all possible avoid modifying the DOM, and do not set inline styles. This is due to JavaScript reflow. An in depth discussion of reflow is out of scope for this article, but a fantastic reference can be found here.
###4.3 Variable Scope and Garbage Collection
Scope all variables as tightly as possible to allow the JavaScript garbage collector to free up your memory sooner rather then later. This is an exceedingly common cause of slow, laggy, non-responsive JavaScript in general and Angular in particular. Be aware of the following problems:
  function demo(){
    var b = {childFunction: function(){console.log('hi this is the child function')};
    b.childFunction();
    return b;
  }
When the function terminates, there will be no further references to b available, and the garbage collector will free up the memory. However, if there is a line elsewhere like so:
var cFunc = demo();
We now bind the object to a variable and maintain reference to it, preventing the garbage collector from cleaning it up. While this may be necessary, it is important to be aware of what effect you are having on object references.
###4.4 Arrays and Objects
There are many things to talk about. The first and simplest is that arrays are always faster then objects, and numeric access is better then non-numeric access.
for (var x=0; x<arr.length; x++) {
    i = arr[x].index;
}
is faster then
(var x=0; x<100; x++) {
    i = obj[x].index;
}
which is still faster then
var keys = Object.keys(obj);
for (var x = 0; x < keys.length; x++){
  i = obj[keys[x]].index;
}
Furthermore, be aware that in modern, V8-based browsers, objects with few properties get a special representation that is significantly faster, so try and keep the number of properties to a minimum. Also be aware that just because JavaScript lets you mix types in an array does not make it a good idea:
var oneType=[1,2,3,4,5,6]
var multiType=["string", 1,2,3, {a: 'x'}]
Any operation on the second will be significantly slower then the first, and not just because the logic needs to be more complex.
Also Avoid using delete. For example, given:
var arr = [1,2,3,4,5,6];
var arrDelete = [1,2,3,4,5,6];
delete arrDelete[3];
Any iteration of arrDelete will be slower then an identical iteration over arr.
This will create a hole in the array making operations much less efficient.
##5 Important Concepts
Now that we have discussed JavaScript performance, it is important to understand a few key Angular concepts that are somewhat "under the hood".
###5.1 Scopes and the Digest Cycle
At their core, Angular scopes are simply JavaScript objects. They follow a predefined prototypical inheritance scheme, an in-depth discussion of which is outside the scope of this article. What is relevant, is that as outlined above, small scopes will be faster then large scopes.
Another observation that can be made at this point, is that any time a new scope is created, that adds more values for the garbage collector to collect "later".
Of particular importance to writing Angular JS applications in general and performance in particular is the digest cycle. Effectively, every scope stores an array of functions $$watchers.
Every time $watch is called on a scope value, or a value is bound from the DOM with interpolation, an ng-repeat, an ng-switch, and ng-if, or any other DOM attribute/element, a function gets added to the $$watchers array of the innermost scope.
When any value in scope changes, all watchers in the $$watchers array will fire, and if any of them modify a watched value, they will all fire again. This will continue until a full pass of the $$watchers array makes no changes, or AngularJS throws an exception.
Additionally, if non-Angular code is run through $scope.$apply(), this will immediately kickstart the digest cycle.
The final note is that $scope.evalAsync() will run code in an async loop that does NOT trigger another digest cycle, and which will run at the end of the current/next digest cycle.
##6 Common Problems: Design with Angular in mind
###6.1 Large Objects and Server Calls.
So what does all of this teach us? The first is that we should think through our data model and attempt to limit the complexity of our objects. This is especially important for objects that are returned from the server.
It is very tempting to simply lob an entire database row over the fence, so to speak, with an eyeroll and an obligatory .toJson(). This can not be stressed enough: don't do that.
Instead use a custom serializer to only return the subset of keys that your Angular application absolutely must have.
###6.2 Watching Functions
Another common problem is the utilization of functions in watchers or bindings. Never bind anything (ng-showng-repeat, etc.) directly to a function. Never watch a function result directly. This function will run on every digest cycle, possibly slowing your application to a crawl.
###6.3 Watching Objects
Similarly, Angular provides the ability to watch entire objects by passing a third, optional true parameter to scope.$watch. This is, for lack of better words, a terribleidea. A much better solution is to rely on services and object references to propagate object changes between scopes.
##7 List Problems
###7.1 Long Lists
If at all possible, avoid long lists. ng-repeat does some pretty heavy DOM manipulation (not to mention polluting $$watchers), so try and keep any lists of rendered data small whether through pagination or infinite scroll.
###7.2 Filters
Avoid using filters if at all possible. They are run twice per digest cycle, once when anything changes, and another time to collect further changes, and do not actually remove any part of the collection from memory, instead simply masking filtered items with css.
This renders $index worthless as it no longer corresponds to the actual array index, but the sorted array index. It also prevents you from letting go of all of the list's scopes.
###7.3 Updating an ng-repeat
It is also important to avoid a global list refresh when using ng-repeat. Under the hood, ng-repeat will populate a $$hashKey attribute and identify items in the set by it. What this means is that doing something like scope.listBoundToNgRepeat = serverFetch() will cause a complete recalculation of the entire list, causing transcludes to run and watchers to fire for every individual element. This is a very expensive proposition.
There are two ways around this. One is to maintain two collections and ng-repeatover the filtered set (more generic, requires custom syncing logic, therefore algorithmically more complex and less maintainable), the other is to use track by to specify your own key (requires Angular 1.2+, slightly less generic, does not require custom syncing logic).
In short:
scope.arr = mockServerFetch();
Will be slower than:
  var a = mockServerFetch();
    for(var i = scope.arr.length - 1; i >=0; i--){
      var result = _.find(a, function(r){
        return (r && r.trackingKey == scope.arr[i].trackingKey);
      });
      if (!result){
        scope.arr.splice(i, 1);
      } else {
        a.splice(a.indexOf(scope.arr[i]), 1);
      } 
    }
    _.map(a, function(newItem){
      scope.arr.push(newItem);
     });
Which will be slower than simply adding:
<div ng-repeat="a in arr track by a.trackingKey">
In place of:
<div ng-repeat="a in arr">
A fully-functional demo of all three approaches can be found here.
Simply clicking between the three options and asking for a refetch demonstrates the point nicely and obviously. One side note it is important to make is that the track by approach only works when a field on the repeated object can be guaranteed to be unique in the set. For server data, the id attribute serves as a natural tracker. If this is not possible, unfortunately the custom syncing logic is the only way to go.
##8. Rendering Problems
A common source of slow Angular applications is incorrect use of ng-hide and ng-show over ng-if or ng-switch. The distinction is nontrivial, and the importance can not be overstated in the context of performance.
ng-hide and ng-show simply toggle the CSS display property. What that means in practice is that anything shown or hidden will still be on the page, albeit invisible. Any scopes will exist, all $$watchers will fire, etc.
ng-if and ng-switch actually remove or add the DOM completely. Something removed with ng-if will have no scope. While the performance benefits should by now be obvious, there is a catch. Specifically, it is relatively cheap to toggle the show/hide, but relatively expensive to toggle if/switch. Unfortunately this results in a case by case judgement call. The questions that need to be answered to make this decision are:
  1. How frequently will this change? (the more frequent, the worse fit ng-if is).
  2. How heavy is the scope? (the heavyer, the better fit ng-if is).
##9. Digest Cycle Problems
###9.1 Bindings
Try and minimize your bindings. As of Angular 1.3, there is a new bind once and forget syntax in the shape of {{::scopeValue}}. This will interpolate from scope once without adding a watcher to the watchers array.
###9.2 $digest() and $apply()
scope.$apply is a powerful tool that allows you to introduce values from outside Angular into your application. It is fired under the hood by angular on all of its events (ng-click, etc). The problem arises in the fact that scope.$apply starts at $rootScope and walks the entire scope chain causing every scope to fire every watcher.
scope.$digest on the other hand starts at the specific scope calling it, and only walks down from there. The performance benefit should be fairly self evident. The trade off, of course, is that any parent scopes will not receieve this update until the next digest cycle.
###9.3 $watch()
scope.$watch() has now been discussed on several occasions. In general, scope.$watch is indicative of bad architecture. There are very few cases when some combination of services and reference bindings can not achieve the same results with lower overhead. If you must create a watcher, always remember to unbind it at the first available opportunity. You can unbind a watcher by calling the unbinding function returned by $watch.
var unbinder = scope.$watch('scopeValueToBeWatcher', function(newVal, oldVal){});
unbinder(); //this line removes the watch from $$watchers.
If you can not unbind earlier then that, remember to unbind in your $on('$destroy')
###9.4 $on$broadcast , and $emit
Like $watch, these are slow as events (potentially) have to walk your entire scope hierarchy. On top of this, being glorified GOTO, they can make your application a convoluted mess to debug. Luckily, like with $watch, they can be unbound with the returned function if absolutely necessary (remember to unbind your $on('$destroy') in your $on('$destroy') and can almost always be avoided outright with judicious use of services and scope inheritance.
###9.5 $destroy
As outlined above, you should always explicitly call your $on('$destroy'), unbind all your watchers and event listeners, and cancel any instances of $timeout, or other asynchronous ongoing interactions. This is not only good practice to ensure safety, and flag your scope for garbage collection more rapidly. Not doing so will keep them running in the background, wasting your CPU and RAM.
It is especially important to remember to unbind any DOM event listeners defined on a directives element in the $destroy call. Failing to do so will cause memory leaks in older browser, and slow down your Garbage Collector in modern browsers. A very important corollary is that you need to remember to call scope.$destroy before you remove the DOM.
###9.6 $evalAsync
scope.$evalAsync is a powerful tool that lets you queue operations up for execution at the end of the current digest cycle without marking the scope dirty for another digest cycle. This needs to be thought about on a case by case basis, but where that is the desired effect, evalAsync can greatly improve your page's performance.
##10 Directive Problems
###10.1 Isolate Scope and Transclusion
Isolate Scope and Transclusion are some of the most exciting things about Angular. They allow the building of reusable, encapsulated components, they are syntactically and conceptually elegant and a core part of what makes Angular Angular.
However, they come with a tradeoff. By default, directives do not create a scope, instead occupying the same scope as their parent element. By creating a new scope with Isolate Scope or Transclusion, we are creating a new object to track, adding new watchers, and therefore slowing down our application. Always stop and think before you use either of these techniques.
###10.2 The compile cycle
Directive's compile functions run before scope is attached and are the perfect place to run any DOM manipulations (binding events for example). The important thing to recognize from a performance point of view, is that the element and attributes passed into the compile function represent the raw html template, before any of angular's changes have been made. What this means in practice is that DOM manipulation done here, will run once, and propagate always. Another important point that is frequently glossed over is the difference between prelink and postlink. In short, prelinks run from the outside in, while postlinks run from the inside out. As such, prelinks offer a slight performance boost, as they prevent the inner directives from running a second digest cycle when the parent modifies scope in the prelink. However, child DOM may not yet be available.
##11 DOM Event Problems
Angular provides many pre-rolled DOM event directives. ng-clickng-mouseenterng-mouseleave,etc. All of these call scope.$apply() every time the event occurs. A much more efficient approach is to bind directly with addEventListener, and then use scope.$digest as necessary.
##12 Summary
###12.1 AngularJS: The bad parts
  • ng-click and other DOM events
  • scope.$watch
  • scope.$on
  • Directive postLink
  • ng-repeat
  • ng-show and ng-hide
###12.2 AngularJS: The good (performant) parts
  • track by
  • oneTime bindings with ::
  • compile and preLink
  • $evalAsync
  • Services, scope inheritance, passing objects by reference
  • $destroy
  • unbinding watches and event listeners
  • ng-if and ng-switch

No comments:

Post a Comment

Angular Tutorial (Update to Angular 7)

As Angular 7 has just been released a few days ago. This tutorial is updated to show you how to create an Angular 7 project and the new fe...