Local Angular scopes

Limit dirty checking to the given scope when reacting to user events.

Main performance bottleneck in AngularJs is the digest cycle and its dirty checking. Each watch expression is checked against its previous value to see if something has changed, and if it has, the change needs to be propagated to the DOM. You can even see how long the digest cycle runs by itself by using a code snippet ng-apply-idle-timing.js. If an idle digest cycle takes longer than a few milliseconds, then you have a performance problem - too many expressions being evaluated, or they are too complex.

One property of the digest cycle has caught my eye. We usually do not call scope.$digest directly. Instead we call scope.$apply, which parses expressions, guards against exceptions, and starts the digest loop. The apply method always starts the digest cycle on the root scope, see source code. If we modify a property on a leaf scope, we still check through every scope, starting with the root.

$rootScope
    scopeA
    scopeB
        scopeC
            foo (modified) 
scopeC.$apply()
// dirty checking properties in $rootScope
// ... scopeA
// ... scopeB
// ... scopeC -> 1 modified property foo

This seems very inefficient, yet every built-in Angular event handler kicks off the $apply() method, see ngEventDirectives.

To avoid unnecessary work, I wrote a local scopes demo where I implemented 2 changes.

1: I added $localApply method to the $rootScope that starts the digest cycle on the current scope, instead of the root. The method is almost identical to the regular $rootScope.$apply

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$rootScope.$localApply = function $localApply(expr) {
try {
this.$$phase = '$apply';
return this.$eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {
this.$$phase = null;
try {
// instead of starting dirty checking at the root
// $rootScope.$digest();
// start at the scope where called
this.$digest();
} catch (e) {
$exceptionHandler(e);
throw e;
}
}
};

2: I copied ng-click to new directive local-click that calls scope.$localApply instead of scope.$apply. Other built-in directives could be cloned and modified the same way.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
directive('localClick', ['$parse', '$rootScope', function($parse, $rootScope) {
var directiveName = 'localClick';
var eventName = 'click';
return {
restrict: 'A',
compile: function($element, attr) {
var fn = $parse(attr[directiveName]);
return function limitedClickHandler(scope, element) {
element.on(eventName, function(event) {
var callback = function() {
fn(scope, {$event:event});
};
// use $localApply instead of $apply
scope.$localApply(callback);
});
};
}
};
}])

The jsbin demo shows the result with 2 scopes: one inside the other. The parent scope is outlined in red, the child scope is outlined in blue.

local scopes screenshot

The scopes have variables n, but the parent scope has heavy processing (finding primes) loop inside the watch expression, thus delaying the dirty checking by around 1 second

1
2
3
4
5
6
$scope.$watch(function () {
delay(); // find primes for a second
return $scope.n;
}, function (newValue) {
console.log('n in parent controller changed', newValue);
});

Child scope has very simple watch expression without a delay, the value n is bound to the HTML using template expression.

1
2
3
4
5
<div id="child" ng-controller="ChildController">
This is child controller. n = {{ n }} Update n in child scope:
<br/> <button local-click="n = 22;">Set n to 22</button> using local-click
<br/> <button ng-click="n = 32;">Set n to 32</button> using ng-click
</div>

There are two buttons in the child scope, both changing value of n. The first button is using the local-click directive shown above, that only dirty checks the current scope (and its children). When you click the first button, there is no delay, the value in the DOM updates immediately.

The second button uses built-in ng-click button that kicks of dirty checking from the root scope, leading to the long watch expression evaluation inside the parent scope, even if it cannot be affected by the change in the child scope in this case.

Conclusions

This is just a small test, and replacing every built-in AngularJS event directive with local- equivalent would not work. For example, services like $timeout and $q are tied to the root scope and need to kick off the full digest cycle. But in some cases, the data changes are isolated to one controller and might not need the expensive apply call. The developer should decide where this is appropriate, similar to making the decision when to use one-way instead of the two-way data binding.

Update 1

I wrote a small code snippet to measure idle local scope digest starting at a scope surrounding an element with given selector ng-profile-local-digest.js. This could be useful to find places on the page with expensive watchers. Another scripe ng-find-expensive-digest.js builds upon ng-profile-local-digest.js to time multiple elements and print table of elements by digest cycle duration

find expensive digest

Update 2

Running just a local digest $scope.$digest() makes a huge performance difference compared to running the full digest cycle $scope.$apply(). Especially when a method can trigger multiple full digest cycles during its execution, see script [ng-count-digest-cycles.js]