Case Study: Using $scope.$digest() As A Performance Optimization In AngularJS
Yesterday, I blogged about using the $scope.$digest() method in AngularJS as an optimization over the more traditional $scope.$apply() method. Since this is a special kind of optimization that cannot be applied in a general manor, I wanted to present a case-study in which the $digest optimization is rather well suited - deferred DOM (Document Object Model) tree transclusion.
Run this demo in my JavaScript Demos project on GitHub.
In the following demo, I have a list that is being rendered by an ngRepeat directive. Inside each list item, I have a primary container that is always visible and a nested container that is only visible upon mouse-over. The nested container may never be rendered, depending on the user behavior. As such, it seems unnecessary for me to have to initialize the various AngularJS bindings inside of the nested container.
To optimize this situation, I'm going to defer the DOM-injection and the subsequent AngularJS binding of the nested container until the user mouses into the primary container. Then, as a further optimization, when the nested container is injected, I will use the $scope.$digest() method (instead of $scope.$apply()) to initialize the injected DOM. This way, the visibility of the changes will be restricted to the local $scope tree, which will save a ton of processing.
NOTE: I am not advocating this as a general practice; I am presenting this as an optimization WHEN and IF it is needed to provide a more performant user experience.
In the following code, the bnDeferDom directive will compile the HTML before the ngRepeat is linked. This is how we are able to modify the HTML that gets cloned in the list.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Case Study: Using $scope.$digest() As A Performance Optimization In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
Case Study: Using $scope.$digest() As A Performance Optimization In AngularJS
</h1>
<!--
The bnDeferDom directive will strip the div.detail out of the DOM at compile-
time, thereby preventing AngularJS from having to implement all of the various
data bindings contained within the div.detail container (ex. ng-src, ng-bind).
-->
<ul bn-defer-dom class="m-friends">
<!--
NOTE: I have an ngClick on this ngRepeat to demonstrate that the watchers
will normally fire with the native directives (see console.log).
-->
<li
ng-repeat="friend in friends"
ng-controller="FriendController"
ng-click="noop()"
class="friend">
<div class="teaser">
{{ friend.name }}
</div>
<div class="detail">
<img ng-src="./avatar-{{ friend.avatarID }}.jpg" class="avatar" />
<div class="name">
{{ friend.name }}
</div>
<div class="nickname">
a.k.a. {{ friend.nickname }}
</div>
</div>
</li>
</ul>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// Let's build a large number of items since that's where the payoff in
// micro-optimizations can really start to be felt.
$scope.friends = buildFriends( 500 );
// I provide a no-action click-handler.
$scope.noop = angular.noop;
// ---
// PRIVATE METHODS.
// ---
// I return a new friend object.
function buildFriend( index ) {
var localIndex = ( index % 3 );
if ( localIndex === 0 ) {
return({
avatarID: 1,
name: "Sarah",
nickname: "Stubs"
});
} else if ( localIndex === 1 ) {
return({
avatarID: 2,
name: "Joanna",
nickname: "J-Diesel"
});
} else {
return({
avatarID: 3,
name: "Tricia",
nickname: "Boss"
});
}
}
// I create a collection of friends with the given size.
function buildFriends( size ) {
var friends = [];
for ( var i = 1 ; i <= size ; i++ ) {
friends.push( buildFriend( i ) );
}
return( friends );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// For this demo, we are going to give a Controller instance to each instance of
// the ngRepeat item. This way, each item can bind watchers while will help us
// demonstrate the point of the deferred bindings.
app.controller(
"FriendController",
function( $scope ) {
// By using a function for the "watch expression", the callback will be
// executed in each $digest. Of course, this is the worst-case scenario
// and is here only to demonstate the [possible] scope of change.
$scope.$watch(
function() {
console.log( "Watcher on $index", $scope.$index );
}
);
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I compile the list and remove the div.detail container before the ngRepeat
// has a chance to compile the repeater. Then, the div.detail container is
// dynamically injected into the DOM just-in-time as the user mouses into the
// given ngRepeat instance.
app.directive(
"bnDeferDom",
function( $compile ) {
// I compile the list before nested directives have a chance to link it.
function compile( tElement, tAttributes ) {
// Find, remove, and compile the div.detail container.
var transclude = $compile( tElement.find( "div.detail" ).remove() );
// When the user mouses into the list item, dynamically inject the
// the div.detail instance. Notice that the first part of the function
// is just guard statements that prevent it from being injected twice.
tElement.on(
"mouseenter",
"li.friend",
function( event ) {
var target = $( this );
if ( target.is( ".defer-complete" ) ) {
return;
}
target.addClass( "defer-complete" );
// The ngRepeat directive creates a new child $scope for
// each instance of the LI. Get the local $scope and create
// a nested child scope for our div.detail container. We
// are doing this to limit the scope of watchers.
var detailScope = target.scope().$new();
// Clone the div.detail container and append to the list item.
transclude(
detailScope,
function( detail, $scope ) {
target.append( detail );
}
);
// Tell AngularJS to configure all the watchers in the newly-
// injected DOM. In this case, we are going to use the
// $digest() method, instead of the $apply() method since
// we don't want to "tell the world" about this change.
// Only the local DOM tree needs to know that anything has
// actually changed.
detailScope.$digest();
}
);
}
// Return the directive configuration.
return({
compile: compile,
restrict: "A"
});
}
);
</script>
</body>
</html>
In addition to the deferred DOM transclusion, I've also included an ngRepeat controller and an ngClick binding. These latter items are here to present a comparison of the $apply() and $digest() methods. When you click on one of the ngRepeat items, it will invoke the ngClick handler which will, in turn, trigger an $apply(). If you look at the browser's console, you can see the flurry of activity triggered by the $apply(). This is to be held in contrast to the complete lack of activity triggered by the $digest() method.
Most of the time, you'll probably have UIs (User Interfaces) that are small enough to diminish the payoff of such optimizations. But, if you do need to present a lot of data to the user, and still want to adhere to the "Angular Way," this type of optimization can have a very noticeable impact on the performance of the application.
Want to use code from this post? Check out the license.
Reader Comments
I wrote a post about $apply and $digest in big projects yesterday. I think we have 2 problems for complicated UI with AngularJS :
- The entire code of AngularJS is wrote to call $apply everywhere. ng- directives, $http, etc.
- In a $digest of a directive, it's not possible to call a $digest in other directives synchronously.
Take a look for examples : https://groups.google.com/forum/#!topic/angular/tswtLq9xbwU
@Xavier,
It's a really interesting problem. It seems that the over-arching "magic" of AngularJS is that is "just works." That magic can, depending on the size of an app, come at a price. Sometimes, every dirty check can be expensive. Sometimes a digest can cause unwanted "paint" events in the browser. But, most of the time, it's not a problem.... until it is.
When it becomes a problem, we try to find ways to minimize the impact of the various digests. I'm intrigued by the idea of being able to trigger a digest in different directives synchronously. It's definitely possible, BUT you are the one that has to wire it together.
I'd like to play around with some ideas on the matter and put together a demo. I think the trick will lie in the fact that digests aren't _required_ for events but, rather for much of the rendering.
Time to put on my thinking cap :D
this is the first time I see such behavior on a UI. For me this is a basic principle to let the developer have control of his UI and not re-check everything.
Thank you for your help.
@Xavier,
I took at stab at answering your question:
www.bennadel.com/blog/2625-triggering-digest-phases-in-related-directives-in-angularjs.htm
Hope that helps!