The Story of Angular Watchers Toggler Directive

Radek Markiewicz

The Story of Angular Watchers Toggler Directive

Genesis

We recently had new clients come to us. They had an application, written in AngularJS, with hard performance issues and heard that we could help them improve it without rewriting half (or more) of the code. I'd like to share the story and solution of one of the problems we encountered. I learned a lot and hope that this post will help people who are looking for similar knowledge. By the way, I encourage you, dear reader, to dig into Angular (or any other framework) internals yourself - I found it fun and inspiring!

The Issue

The problem we wanted to solve was about ng-repeat directive (re)drawing a set of very heavy elements. By heavy element I mean one that includes two levels of nested ng-repeat and multiple larger directives inside and around it. The average set of 45 elements contains almost 7,000 scopes and 4,000 watchers on one page. Drawing those 45 elements by ng-repeat takes up to 10 seconds, and I wouldn't consider my machine slow.

We addressed the page loading time issue in a separate story. The issue here is related to a filtering feature - originally it just removes/adds elements to ng-repeat array, so disabling/changing filters causes a (re)draw of many those heavy elements - it lasts too long. We decided to solve this by leaving ng-repeat array untouched and show/hide the elements that are actually rendered (for example by ng-hide). In the original verision, filtered-out element's were removed, so their watchers were not slowing down Angular's digest cycle. We wanted to achieve the same result. This is how we solved the issue and how the watchers toggler directive idea was born.

Main idea

Requirements for the directive:

  • hide/show element depending on a provided boolean value

  • disable/enable watchers of a given element and all its children elements' scopes

  • handle new watchers created for disabled scope

  • refresh the whole mechanism if value provided as optional argument changes. I think about ng-repeat nested inside directives main element. We want to include possible new children scopes into the set of disabled scopes

  • one additional thing (according to this particular application) - store disabled watchers somewhere else than the related scope itself. There is too much garbage in the scopes already

Important Angular characteristics

At this point I'd like to shortly describe a few of Angular's characteristics, important in the context of building such a directive. It should help understand what's going on for people not familiar with digest cycle magic.

Watchers and digest cycle

In short, an Angular digest cycle iterates through all the scopes and evaluates objects called watchers. Those watchers are responsible for most Angular magic like two-way binding, reacting on model changes, etc. The more watchers exists, the more time the digest cycle needs to iterate and evaluate them all. A long digest cycle usually means lag and poor application performance. That's why we need a directive to disable watchers of not visible elements.

Watchers are stored in every scope's property called $$watchers. It's just a simple array of usual javascript objects. To exclude them from the digest cycle, we need to store them somewhere else and empty the original array. To enable them again, we just perform the reverse operation. Adding watchers to $$watchers is handled by $watch function, defined on the scopes' prototype. To automatically disable new watchers, we can, for example, overwrite that function to store them somewhere else.

Scopes hierarchy

Scopes in AngularJS are structured in a tree hierarchy - every scope has one parent (excluding $rootScope) from which it inherits properties and can have multiple siblings and children. Every scope holds the references to it's parent, next/previous sibling and first/last child. We can use those references to traverse through the scopes tree and disable their watchers, starting with just one scope. There is one unusual kind of scope, called isolated scope. It's unusual, because it does not inherit from it's parent. Fortunately, in the context of tree traversing, it holds references to parent/siblings/children just like normal scope.

This should be enough to give you a more complete understanding of the directive.

Step by step

Now I'd like to describe, step by step, how the directive works.

Directive configuration

module
.directive('watchersToggler', ['$parse', '$timeout', function($parse, $timeout) {
  return {
    restrict: 'EA',
    scope: {
      toggler: '&watchersToggler',
      refreshSuspensionOn: '=refreshHideOn'
    },
    link: function($scope, element) {
      var watchers = {
        suspended: false
      };
      (...)
    }
  }
}]);

Nothing unusual here. Directive can either be element or attribute. Isolated scope contains:

  • toggler - the boolean value that causes enabling/disabling watchers. & sign means that it's a function returning value evaluated in the context of parent scope

  • refreshSuspensionOn - value that indicates changes in the scopes structure. We will watch and suspend any new scopes on change

The whole directive's logic is inside link function. Watchers are stored in watchers object.

Watchers toggler

$scope.$watch(function() { return $scope.toggler() }, function(newToggler, oldToggler) {
  if(typeof newToggler == 'boolean') {
    if(newToggler) {
      element.hide();
      suspendFromRoot();
    } else {
      element.show();
      resumeFromRoot();
    }
  }
});

When the value returned by toggler changes, we perform the proper actions:

  • hide element and suspend (disable) watchers when toggler is true

  • show element and resume (enable) watchers otherwise

Disabling watchers

function suspendFromRoot() {
  if(!watchers.suspended) {
    $timeout(function() {
      suspendWatchers();
      watchers.suspended = true;
    })
  }
}

function suspendWatchers() {
  iterateSiblings($scope, suspendScopeWatchers);
  iterateChildren($scope, suspendScopeWatchers);
};

function suspendScopeWatchers(scope) {
  if(!watchers[scope.$id]) {
    watchers[scope.$id] = scope.$$watchers || [];
    scope.$$watchers = [];
    scope.$watch = mockScopeWatch(scope.$id)
  }
}

If watchers are not disabled already, we disable them and set the flag. Those steps are wrapped in $timeout to apply the changes at the end or after the current digest cycle.

iterateSiblings and iterateChildren, as you guessed, iterate over siblings/children of a given scope and perform the function given as a second argument. In this case it's suspendScopeWatchers which stores a scope's watchers in watchers object, using scope.$id as the unique key. It also assigns an empty array to $$watchers. That's it - watchers for the current scope are disabled.

There are two quirks that you have to be aware of.

We need to iterate through directive's main isolated scope siblings, because of funny Angular characteristic. If you create a directive with isolated scope but without template (like mine), scopes created below in DOM tree will be it's siblings, not children.

If parent scope of this directive is common with other scopes (directives, controllers), there is possibility that there will be an undesired leak of watchers suspension. I mean, in some cases you can disable watchers of sibling scopes which are siblings to this directive in DOM tree. In my case it works perfectly, because I use it on ng-repeated elements and ng-repeat creates separate scope for every iteration.

var mockScopeWatch = function(scopeId) {
  return function(watchExp, listener, objectEquality, prettyPrintExpression) {
    watchers[scopeId].unshift({
      fn: angular.isFunction(listener) ? listener : angular.noop,
      last: void 0,
      get: $parse(watchExp),
      exp: prettyPrintExpression || watchExp,
      eq: !!objectEquality
    })
  }
}

In a previous snippet, we overwrote a scope's $watch function with the result of mockScopeWatch function. It takes scope.$id as an argument and handles new watchers created on disabled scopes - it adds them to stored watchers. The shape of stored watcher is inspired by original $watch. It does not cover all edge cases as the original does, but it's enough for us.

Iterating over scopes

function iterateSiblings(scope, operationOnScope) {
  while (!!(scope = scope.$$nextSibling)) {
    operationOnScope(scope);
    iterateChildren(scope, operationOnScope);
  }
}

function iterateChildren(scope, operationOnScope) {
  while (!!(scope = scope.$$childHead)) {
    operationOnScope(scope);
    iterateSiblings(scope, operationOnScope);
  }
}

As I said before, every scope holds references to it's siblings and children. Here is a practical example of using those references to traverse through the scopes tree.

scope.$$nextSibling points to the next sibling in the hierarchy, allowing us to walk horizontally through a particular tree/branch level. For every scope we perform a given operation and traverse through the children of the scope.

scope.$$childHead points to first child of every scope. This allows us to walk vertically from the scope to the lowest level of it's children. On every level we perform horizontal traversing, as described above.

This way we can reach every scope below and next to the main directive's $scope to disable/enable watchers.

Enabling watchers

function resumeFromRoot() {
  if(watchers.suspended) {
    $timeout(function() {
      resumeWatchers();
      watchers.suspended = false;
    })
  }
}

function resumeWatchers() {
  iterateSiblings($scope, resumeScopeWatchers);
  iterateChildren($scope, resumeScopeWatchers);
};

function resumeScopeWatchers(scope) {
  if(watchers[scope.$id]) {
    scope.$$watchers = watchers[scope.$id];
    if(scope.hasOwnProperty('$watch')) delete scope.$watch;
    watchers[scope.$id] = false
  }
}

This part is similar to disabling scopes - we traverse through the scopes tree to enable watchers. Enabling consists of:

  • reassigning stored $$watchers array

  • removing mocked $watch to restore original prototype.$watch

  • removing stored watchers.

Refreshing watchers suspension

$scope.$watch('refreshSuspensionOn', function(newVal, oldVal) {
  if(newVal !== oldVal) refreshSuspensionFromRoot()
}, true);

function refreshSuspensionFromRoot() {
  if(watchers.suspended) {
    $timeout(function() {
      suspendWatchers();
    })
  }
}

Lastly, we have an optional feature. If the given parameter's value changes, we want to refresh the watchers suspension. As you can see, it already uses an existing mechanism for disabling watchers. Thanks to mocked $watch we know that already disabled watchers are in correct state so we don't have to pay any attention to them and just look for new scopes.

That's it. Directive done and now we're ready to work. The whole code is available here: https://gist.github.com/RadoMark/fbd501b26e0c389c4135

Inspirations

Although I invented the directive's flow myself, I found some community inspirations/snippets that helped me make it shorter and more efficient. I'd like to mention:

Summary

The above code works and works well. It solved our problem, which is what counts the most. I'm aware that it's not ideal and things could be done better. If you see something that could be tweaked or improved and are eager to share - any feedback would be much appreciated. I'd like to say it loud again - I encourage you, dear reader, to dig into Angular (or any other framework) internals yourself - I found it fun and inspiring!

Radek Markiewicz avatar
Radek Markiewicz