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 | $rootScope.$localApply = function $localApply(expr) { |
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 | directive('localClick', ['$parse', '$rootScope', function($parse, $rootScope) { |
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.
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 | $scope.$watch(function () { |
Child scope has very simple watch expression without a delay, the value n
is bound to the
HTML using template expression.
1 | <div id="child" ng-controller="ChildController"> |
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
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]