Skip to content

dragonflypl/angular-performance-seed

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Tools

  • https://github.com/mrdoob/stats.js/
  • AngularJS Batarang
  • AngularJS Inspector
  • ng-inspector for AngularJS
  • Angular watchers
  • console.time API along with Chrome Timeline & Profiler!

Scopes

Scopes:

  • is a glue between controller and view (data use to render the view)
  • represents application model
  • are a context for expressions (e.g. {{someExpression()}}. Expressions are evaluated on the scopes.
  • arranged in a hierarchy (child / isolated scopes)

Phases

Linking

Watchers are set for found expressions by the directives. During template linking, directives register watches on the scope. This watches are used to propagate model values to the DOM.

Hint, ng-bind code: https://github.com/angular/angular.js/blob/master/src/ng/directive/ngBind.js#L3

git checkout 03-watcher-execution

Hierarchy

Scope inheritance is prototypal. It means that when expression is evaluated, it is first evaluated on current nodes scope. If not found, it goes down the inheritance chain.

git checkout 02-app-controller-inheritance

Hint: to access currently accessed element, use $0 variable in console. To retrieve associated scope use angular.element($0).scope() or angular.element($0).isolateScope(). Both functions are available only when debugInfoEnabled() is true.

angular.element($0).scope().id evaluated on child node, returns AppController.

Also:

angular.element($0).scope().__proto__ == angular.element($0).scope().$parent

prove that we're dealing with prototypal inheritance.

$rootScope

Each application has single $rootScope. Each scope (child/isolated) has a reference to $rootScope

$apply & $digest & $watch

All changes to the model (scope) must be done inside Angular's execution context (i.e. digest cycle must be triggered).

To enter this context, $apply method can be used:

Angular's code: https://github.com/angular/angular.js/blob/master/src/ng/directive/ngEventDirs.js#L3

$apply is just a helper/wrapper method that calls $rootScope.$digest after client's code runs (at the end of $apply). A digest cycle starts with $scope.$digest() call.

function $apply(expr) {
  try {
    return $eval(expr);
  } catch (e) {
    $exceptionHandler(e);
  } finally {
    $root.$digest();
  }
}

It's important to notice, that it calls $digest on $rootScope. That means that all watchers (watchExpressions) will be called on each digest cycle loop iteration!

Angular directives / services automatically call $digest (e.g. ng-click, $timeout)

Having this in mind remember: dirty checking function must be efficient and fast.

What if a listener function itself changed a scope model?

In $digest scopes examine all of the $watch expressions and compare them with the previous value. It's called dirty checking. If current value is different from previous, $watch listener is executed.

$digest is repeated untill there're no changes ($watch'ers do no detect any changes)

git checkout 04-num-digest-loops + show window.watchers

image

Dirty checking is done anynchronously - not immediately - when call stack becomes empty.

If $watch causes changes of the value of the model, it will force additional $digest cycle.

Let's see how it looks in dev tools:

image

image

$evalAsync

This is addition to the $digest cycle. In reality, apart from $watch list, Angular is storing additional queue: $evalAsync queue. This is useful when we need to execute some code asynchronously i.e. at the beginning of next digest cycle loop. Putting something into this queue will enforce additional digest iteration.

git checkout 05-eval-async

Also use $applyAsync to queue async code that will be run before next $digest cycle.

Difference:

  • $evalAsync queue is flushed inside digest loop, at the beginning of the iteration.
  • $applyAsync queue is flushed before digest loop, at the beginning of dirty checking

Let's thing about consequences...

$watch strategies

  • by reference (good) using !== (angular's default)
  • by value (bad) using angular.copy (creates deep copy) - can have memory / performance implications
  • watching collection content (ugly, but needed sometimes) with $watchCollection. Notifies about changes in collection (add/removal/replacement)

Also there's helper $watchGroup.

git checkout 06-watch-strategies-strict-non-strict

Hints

Watches are set on:

  • $scope.$watch
  • {{ }} type bindings
  • Most directives (i.e. ng-show)
  • Scope variables scope: { bar: '='}
  • Filters {{ value | myFilter }}
  • ng-repeat

Watchers (digest cycle) run on:

  • User action (ng-click etc). Most built in directives will call $scope.apply upon completion which triggers the digest cycle.
  • ng-change
  • ng-model
  • $http events (so all ajax calls)
  • $q promises resolved
  • $timeout
  • $interval
  • Manual call to $scope.apply and $scope.digest

How to improve performance:

  • use digest instead of apply
  • More DOM manipulation in Directives (e.g. swich classes in onclick event, without watchers) link function
  • use ng-if in favour of ng-show/hide
  • use classList
  • use track by (by default is uses $watchCollection and reference identity) in ngRepeat
  • debounce ng-model with ng-model-options
  • use one time binding (::)
  • make sure onetime binding is "stable"
  • disable ngAnimate globally / enable is explicitly with $animateProvider.classNameFilter: https://www.bennadel.com/blog/2935-enable-animations-explicitly-for-a-performance-boost-in-angularjs.htm
  • use WeakMap / WeakSet (never hold a reference to DOM elements)
  • throttle / debounce mouse events (do not use angulars directives for mouse events)
  • caching ($http with $cacheFactory)
  • don't use deep watch $watch. Switch from deep watch to $watchCollection. If deep watch must be used, watch only subset of data (_.map)
  • use native JavaScript & lodash
  • delayed transclusion: ng-if / switch are cool as they delay linking of a DOM elements (as a result, delay watchers creation)
  • don't use filters for sorting!
  • clean after yourself in $destroy ($watch,$on,$timeout), unbind watchers
  • make manual watchers lightning fast / reduce number of watchers :)
  • 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
  • use applyAsync (group many async operations into one digest)
  • check compileProvider settings (debugInfoEnabled, *DirectivesEnabled)

How to improve performance (to prove):

Hands on performance

git checkout 10-performance-data-seed

  • we have two sets of data and table with 3000 rows
  • 27004 initially set (window.watchers)
  • we have instrumented $rootScope.$digest to get feedback when full digest runs & how much time it takes
  • stats.js enabled to see memory usage / FPS
  • progress bar enabled to spot UI freezes

Initial impression

  • page is working
  • watchers are waiting
  • FPS good
  • short freeze when binding the data

Let's trigger full digest every 3 seconds:

    setInterval(function triggerDigest() {
     $scope.$root.$apply();
    }, 3000);  

image

Conclusion: JavaScript / watchers execution is blazing fast: 27004 executed on 0.2 second.

Let's add more statistics:

    var properties = {};
    
    $scope.show = function(item, property) {      
        properties[property] = (properties[property] || 0) + 1;
      return item[property];
    }   
    
    setInterval(function triggerDigest() {
        properties = {};
        $scope.$apply();
        for(let property in properties) {
            console.log(property + ' called ' + properties[property] + " times");
        }    
    }, 3000);    

We get to know how many times watch callbacks are called per digest cycle. Initially it is 3000 times when no model changes. Let's change a model:

git checkout 11-model-change

Quiz: how many times watchers will be called ?

image

But what will happend if we modify last item in the table:

image

What about item it the middle:

image

Conclusion: Wow! Angular optimizes digest loop (just like JIT), so it's not as dummy. So sometimes, you will not know why something is happening or not happening.

Ok, let's measure how modification of the model affects digest loop times:

git checkout 11-model-change-measure

Times are doubled.

image

Let's modify all id properties. Time increased a bit due to DOM updates. Let's update more stuff in model (balance).

git checkout 12-more-model-changes

Now we get 500ms digest cycle (but this is only JavaScript!) + a great deal of repaint:

image

Now, everything takes more than 1 second (1.2sec) that can already cause flickering. But this is still not bad (but not something to be proud of), provided we don't cause digest cycles to often.

Conclusion: both JavaScript & Rendering are responsible for user experience & amount of work browser has to perform.

Filters

Filters are commonly used in presentation layer to format data. Let's see them in action and generate links for emails using built-in filter <td ng-bind-html="show(item, 'email') | linky"></td>.

Before: $rootScope.$digest 448 ms. After: $rootScope.$digest 1479 ms.

Keep in mind that number of watchers did not change

Adding one, seemingly straightfowrard, filter increased digest time by 1sec. We did not change emails so, seemingly, angular should ignore the filter. However, filters are not assumed to be pure functions - they could return different value for the same input. What is more, let's check how many times filters are called (we'll implement custom filter).

git checkout 12-filter-measure

Indeed, filters are called as many times as other watcher. So whole expression (with filter) is evaluated.

Faster alternative:

<td>
  <a href="mailto:{{show(item, 'email')}}">{{show(item, 'email')}}</a>
</td> 

app.js:24 $rootScope.$digest 514 ms. back to normal time. We already proved that simple expressions are blazing fast, so even though we evaluate show(item, 'email') twice, there's basically no difference in time.

ng-repeat filter

Let's add ng-repeat filter and see how it behaves.

git checkout 13-ng-repeat-filter

Note that all model changes are disabled. Once again we see that filter is evaluated for each row each. So we filtering array even though nothing is changing.

Alternative: use $watchCollection for filter object & source data:

git checkout 14-ng-repeat-manual

But let's check what is happening when we type change filters : many digest cycles after each keyup event.

We can fix it with ng-model-options="{ debounce: 500 }".

Bottom Line: don't use filters for filtering in ng-repeat

styling

git checkout ng-class 15-ng-class-many

We have 15k additional watchers + rendering time doubled (1100ms). Let's do some extreme optimization that include manual dirty checking & low level classList API (reduced number of watchers to only additional 3k + only +100ms additional digest time instead of +500ms).

git checkout ng-class 16-ng-class-many-optimized

one-time binding

For watchers, that're not interested in expression value changes or value never changes, use one-time binding ::. Let's assume that balance does not change.

git checkout 18-one-time-enabled

Note, that Batarang has a bug that prevents one-time binding from working, so for the purpose of this exercise disable it.

But... Number of watchers should decrease by number of rows, but it did not. This is because of "Value stabilization algorithm" implementation (https://docs.angularjs.org/guide/expression) .

To fix it (provided we're sure that balance will never change) we need to cheat and stabilize the expressions value:

<td ng-bind="::(show(item, 'balance') || '')"></td>

track by

By default, ng-repeat is using identity comparsion (reference) to spot if data has changed and DOM should be updated. Let's reload the data & see UI experience & have a look at profiler (rendering & scripting times).

git checkout 17-no-track-by

Each data reload freezes UI, even though nothing has changed in the model.

Now, let's add track by item.id. No rendering & no additional scripting (except regular digest cycle watch execution).

ng-if vs ng-show

git checkout 19-ng-if-or-show

ngShow uses CSS to show hide elements: as a result they still exist in DOM and all directives are executed (thus watchers).

ngIf removes / adds to DOM, thus compilation/linking is triggered. However no watchers are set when element is not visible.

DOM operations are slower so make right call - if elements are often toggled then ngShow would be better (no compilation/linking each time element is toggled). Otherwise ngIf would be preferred.

Another usage could be with email col. Let's assume we want to show email when cell is clicked:

<td class="email-col" ng-click="showEmail = true">        
  <a ng-show="showEmail" href="mailto:{{show(item, 'email')}}">{{show(item, 'email')}}</a>
</td> 

With ngShow we have additional watchers, and a is in the DOM anyway, just display: none.

Changing to ngIf defers anchor creation to the moment when this is actually needed:

git checkout 20-ng-if-makes-sense

What else can be improved? Use oneTime binding on showEmail and no ngInit. Once cell is clicked, we save one watcher :)

But, there're more improvements that can be made...

More DOM manipulation in Directives

Make Directives, not war! Current example has following things that could be improved:

  • ngClick handler is not detached
  • we trigger full digest with ngClick even though nothing changes in the model (apart from helper variable)
  • email cell is still compiled & linked (ngIf directive) once at page load to figure out that is should remove itself from the DOM

Let's refactor it:

git checkout 21-directive-beauty

Few things to notice:

  • usage digest instead of apply (we do not trigger full $digest for all watchers, only for row scope)
  • we unbind event handlers (one)
  • we have no single watcher added

Event delegation

Let's check how many event handlers do we have registered on the page:

git checkout 22-event-delegation

(this may seem not needed, but will make more sense when we add e.g. virtual scroll)

git checkout 23-event-delegation-implemented

angular-animate

By enabling ngAnimate module, some directives (like ngRepeat) will perform additional work to mark DOM elements with special classes that enable animations. Let's enable animations globally and see what will happen on page load & data reload.

git checkout 24-ng-animate

Initial/Reload digest cycle increased from ~1sec to 5sec, even though we don't have any animations on the page.

Solution is to disable ngAnimate completely or apply animation filter:

$animateProvider.classNameFilter( /\banimatable\b/ );

WeakMap/WeakSet (do not hold reference to DOM elements)

Let's assume we want to extend userActions directive to execute revealEmail action only once per row.

git checkout 25-memory-leak

And analyze profile snapshot.

Now let's do the same for solutions with WeakSet:

git checkout 26-weakset

Throttle / debounce

Simple as that : for mouse events / stream of events use _.throttle / _.debounce and in general avoid mouse directives as they trigger full digest cycle .

$cacheFactory

Angular has built in cache mechanism (e.g. used to cache templates). It works smoothly with $http service and it is possible to cache responses independently from other caching mechanisms (like Cache-Control).

Let's enable throttling & enable/disable data caching.

git checkout 27-cacheFactory

$compileProvider & $httpProvider

Use providers settings to speed up the app.

Do not show too much

  • use infinite/virtual scrolling

git checkout 28-virtual-scroll

$broadcast vs $emit

$$watchers

Detaching $$watchers for scopes outside of viewport:

http://engineering.curalate.com/2016/01/17/angular-perf.html

ngRepeat chunking

git checkout 10-performance-data-seed

Initial experience slow - UI freeze - show one long digest cycle and 3000sec frame

git tag 29-chunking

Experience better but longer time to render whole table, play around with chunk size

$applyAsync

Let's assume we have a directive that is doing some animation of visible content : balance-animation

TODO:

measure idle digets cycle loop time:

angular.element(document.querySelector('[ng-app]')).injector().invoke(function($rootScope) { 
  var a = performance.now(); 
  $rootScope.$apply(); 
  console.log(performance.now()-a); 
})

TODO-DONE