Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
I am the chief technical officer at InVision App, Inc - a prototyping and collaboration platform for designers, built by designers. I also rock out in JavaScript and ColdFusion 24x7.
Meanwhile on Twitter
Loading latest tweet...
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with:

Triggering $digest Phases In Related Directives In AngularJS

By Ben Nadel on

In a previous post, I took a look at using the $scope.$digest() method in AngularJS as a possible performance optimization, over $scope.$apply(), in situations where you know that your view-model changes are local. In the comments to that post, Xavier Boubert asked about applying this optimization in several related directives at the same time. I've never done this personally, but it was an interesting question so I figured I'd take a stab at an answer.


 
 
 

 
  
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

The key to my approach is understanding what aspects of AngularJS actually rely on the $digest lifecycle. Because AngularJS is so magical, and presents such a clean separation of concerns, it's easy to forget that it's just JavaScript; and, that it's not entirely driven by dirty-data checks. All the method invocation, all the event propagation - that's just vanilla JavaScript. The parts of AngularJS that are primarily concerned with dirty-data are the $watch() handlers.

I say all this because it means that we can use the Pub/Sub (Publish and Subscribe) mechanism, provided by the $scope chain, without initiating a $digest. This will allow our directives to use event-based (ie, decoupled) communication while keeping our $digests localized.

In the following exploration, I have two directives that listen for mouse-events and output the X/Y coordinates of the mouse cursor. When the "Left" directives captures a mouse event, it consumes it locally and then announces an event on the $rootScope. The "Right" directive then listens for that event and consumes it as well. Both the Left and the Right directives keep the $digest phase local to the current $scope.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Triggering $digest Phases In Related Directives In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Triggering $digest Phases In Related Directives In AngularJS
  • </h1>
  •  
  • <!-- Controller creates a scope. -->
  • <p ng-controller="TopController" ng-click="incrementCount()" class="top">
  • Click Count: <em>{{ clickCount }}</em>
  • </p>
  •  
  • <!-- Controller creates a scope. -->
  • <p ng-controller="MouseController" bn-left class="left">
  • X: <em>{{ x }}</em>,
  • Y: <em>{{ y }}</em>
  • </p>
  •  
  • <!-- Controller creates a scope. -->
  • <p ng-controller="MouseController" bn-right class="right">
  • X: <em>{{ x }}</em>,
  • Y: <em>{{ y }}</em>
  • </p>
  •  
  •  
  • <!-- 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 ) {
  •  
  • // This watch is here just to show us when this $scope is involved in a
  • // $digest phase of the dirty data lifecycle.
  • $scope.$watch(
  • function() {
  •  
  • console.log( "App controller $watch." );
  •  
  • }
  • );
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control the top box.
  • app.controller(
  • "TopController",
  • function( $scope ) {
  •  
  • $scope.clickCount = 0;
  •  
  • // This watch is here just to show us when this $scope is involved in a
  • // $digest phase of the dirty data lifecycle.
  • $scope.$watch(
  • function() {
  •  
  • console.log( "Top controller $watch.", $scope.clickCount );
  •  
  • }
  • );
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I increment the click count.
  • $scope.incrementCount = function() {
  •  
  • $scope.clickCount++;
  •  
  • };
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control one of the target boxes.
  • app.controller(
  • "MouseController",
  • function( $scope ) {
  •  
  • // I hold the coordinates of the current mouse cursor on the page.
  • $scope.x = 0;
  • $scope.y = 0;
  •  
  •  
  • // ---
  • // PULIC METHODS.
  • // ---
  •  
  •  
  • // I set the new coordinates for the mouse cursor.
  • $scope.setCoordinates = function( newX, newY ) {
  •  
  • $scope.x = newX;
  • $scope.y = newY;
  •  
  • };
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I manage the JavaScript events for the Left target box. When the mouse
  • // coordinates are updated within the left target box, we're going to get the
  • // Right target box to show the same values. Furthermore, we're going to limit
  • // dirty-data checking to the $scopes LOCAL to the target boxes.
  • app.directive(
  • "bnLeft",
  • function( $rootScope ) {
  •  
  • // I bind the JavaScript events to the local scope.
  • function link( $scope, element, attributes ) {
  •  
  • element.mousemove(
  • function( event ) {
  •  
  • // Get the mouse coordinates.
  • var x = event.pageX;
  • var y = event.pageY;
  •  
  • // Tell the Controller about the cooridinates of the mouse.
  • // At this point, the Controller will "know" about the
  • // change; but, "AngularJS" won't know about the change since
  • // this is happening out side of $digest.
  • // --
  • // CAUTION: This method is being invoked outside of Angular's
  • // exception handling. One of the benefits of using the
  • // $apply() method is that you are inside of AngularJS' error
  • // handling framework.
  • $scope.setCoordinates( x, y );
  •  
  • // Tell AngularJS about the LOCAL change to the view-model.
  • $scope.$digest();
  •  
  • // Announce the event so that our other directive can react
  • // accordingly.
  • // --
  • // NOTE: AngularJS will propagate the event outside of a
  • // $digest; but, it won't fire any $watch statement until the
  • // next digest.
  • $rootScope.$broadcast( "leftCoordinatesChanged", x, y );
  •  
  • return;
  •  
  • // --- !! THIS WILL NOT EXECUTE, JUST HERE FOR FUN !! --- //
  •  
  • // NOTE: If you didn't want to through the entire scope chain,
  • // you could * theoretically * go through the DOM and
  • // directly target the other directive's scope. Of course,
  • // the downside to this is that you have to query the DOM and
  • // you couple yourself much more tightly to the other
  • // directive, which you don't really want to do (for a number
  • // of reasons). You're much better off just using an event
  • // that goes from the root scope, down.
  •  
  • $( "p[ bn-right ]" )
  • .scope()
  • .$broadcast( "leftCoordinatesChanged", x, y )
  • ;
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // Return the directive configuration.
  • return({
  • link: link,
  • restirct: "A"
  • });
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I manage the JavaScript events for the Right target box.
  • app.directive(
  • "bnRight",
  • function() {
  •  
  • // I bind the JavaScript events to the local scope.
  • function link( $scope, element, attributes ) {
  •  
  • // Listen for mouse events in the current view.
  • element.mousemove(
  • function( event ) {
  •  
  • // Tell the Controller about the new coordinates.
  • $scope.setCoordinates( event.pageX, event.pageY );
  •  
  • // Tell AngularJS about the LOCAL change to the view-model.
  • $scope.$digest();
  •  
  • }
  • );
  •  
  •  
  • // In addition to the local mouse events, we're also going to be
  • // listening for events that were triggered by the other directive.
  • // When those events come through, we'll treat them like they were
  • // mouse events on the current view.
  • $scope.$on(
  • "leftCoordinatesChanged",
  • function( event, leftX, leftY ) {
  •  
  • // Tell the Controller about the communicated coordinates.
  • $scope.setCoordinates( leftX, leftY );
  •  
  • // Tell AngularJS about the LOCAL change to the view-model.
  • $scope.$digest();
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // Return the directive configuration.
  • return({
  • link: link,
  • restirct: "A"
  • });
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

In addition to the Left and Right directives, I also have two "higher up" Controllers. These are there in order to demonstrate that their $watch() handlers are not invoked during this whole directive-communication lifecycle (best seen in the video above).

The great thing about the $apply() method, in AngularJS, is that it "just works." But, with a large and complex user interface, the cost of that simplicity can be a sacrifice in performance. The good news is, AngularJS is architected in such a way that there always seems to be an "Angular way" to optimize, if and when you need it.




Reader Comments

Thank you for your post and your Stab. I'm ok with you, for this problem we can workaround. But it means we can rewrite ALL of the ng-directive and other objects like $http. WoOt.

My second point with "digest by feature" is "How can I call the digest of an other feature inside a digest?" Actually it's not possible without asynchronous behavior. So, in complex UI, without blinks. (Try with D&D)

I don't understand why it does not shock anyone to have to call the refresh of the entire page, even for small projects.

But again, really thank you for your great help ;-)

Reply to this Comment

@Xavier,

As of AngularJS 1.2, it's possible to safely invoke a $digest from within another $digest if you use the $evalAsync() method:

http://www.bennadel.com/blog/2605-scope-evalasync-vs-timeout-in-angularjs.htm

Essentially what this is does is, rather than invoking a new $digest immediately, it adds the callback to the end of a queue that gets checked at the end of the $digest iteration. So, you're callback will get picked up in the *current* digest... or, if the current $digest is already over, it falls-back to an asynchronous digest (via [essentially] a timeout call).

The latter part of that - the "fallback" to an asynchronous $digest - is what was added in v1.2.

THAT said, it may not be as bad as you think. If you're dealing with one directive communicating with another, chances are that you will always *know* whether or not a digest is in effect since you typically have to trigger them yourself inside a directive.

Reply to this Comment

@Xavier,

I think maybe you are too worries about certain aspects of directives. Yes, there are times when you do not know for certain if a $digest is already in place; however, since it's the developers responsibility to let AngularJS know when to execute a digest (from within a Directive) I would say that in the large majority of cases, it will never be ambiguous as to whether or not a digest is already running.

Do you have a particular use-case you are fighting against? Or are you just trying to wrap your head around the dirty-data-checking approach?

Reply to this Comment

As I said in my JSFiddle example, me and my team are actually creating a IDE (WYSIWYG) webservice (like Microsoft Blend or Dreamweaver for example). There are many features in single page like :
* "Edition area" that may contain components. It manages D&D and resizable for each components added
* A specific panel with properties of selected components
* And many other panels and menus like "Layers explorer", "List of components to insert in Edition area", toolboxes with "save", "copy", "paste", etc.

In this project, we have hugely directives, filters, watchers, etc.

One of my cases:
The user changes the "size" property of a selected component in the "Properties panel". When he presses keyboard keys, I want to update ONLY the component view in the edition area. If AngularJS calls $apply at this time, all of the page will be dirty-data-checked. I don't want that because it's too slow for my big interface.

At this step, I successfully remove many $apply calls. But it means that I can't use ng- directives, like ng-click to focus the "with" text input for example. It's my first problem.

My second problem: The "width" value is a ng-model wrapped on a JS object. When this object changes, I have a $watch wich is called. In this $watch I want to $digest the component view in "Edition area". Without setTimeout, it simply doesn't possible as we are talking about since the beginning of this post.

I hope that what I say is clear enough ^^

Reply to this Comment

@Xavier,

Sounds like you are building a really complex app. I've been building a fairly large AngularJS app - but, at any one time, there aren't toooo many things on one page, they way there might be an IDE. It's certainly an interesting problem.

In general, I think you are right, though - the more specialized and optimized your app needs to become, the less you are going to use the out-of-the-box directives that AngularJS comes with.

Good luck! What an interesting conversation :D

Reply to this Comment

Thanks Ben!

I'm thinking about forking project to make an other AngularJS optimized or to make a special module. But I'm afraid I do not have time for this.

Reply to this Comment

@Xavier,

Ha ha, yeah, that sounds like it would be a huge undertaking. You'd probably be better off just writing a few custom directives. Of course, another thing to take into account is that non-directive things also cause full-scope $digests, like $timeout() and $q.

Now that I saw that, I wonder if $http causes an $apply as well.... it mus, otherwise AngularJS wouldn't know how you applied the response data.

Ok, so if you create some key custom directives, and build a custom $timeout, $q, and $http service, you should be ok.... he says jokingly as that clearly represents a LOT of work :)

Reply to this Comment

Make new directives and $ functions are not very complicated to make.

The second problem is more complicated. Call 2 digests views without $apply and deffer to refresh 2 parts of view in single shot is my real problem.

My job is to create very large web platforms. For now, only ExtJS works like a charm with big projects

Reply to this Comment

@Xavier,

If you do start going down that road, I'd really like to hear about the experience. Good luck!

Reply to this Comment

Hey

Thanks for sharing your interesting insights and the workarounds :)

AngularJS strengths for small apps are a weakness for big apps and you have to workaround with custom directives and an event bus. BTW you'd better use $rootScope.$emit and $rootScope.$on VS the expensive broadcast, as explained by Christoph here : http://stackoverflow.com/questions/11252780/whats-the-correct-way-to-communicate-between-controllers-in-angularjs/19498009#19498009

Sometimes you need to make an hybrid approach integrating non-angular stuff inside your angular app. For example for the IDE, maybe the "wysiwyg" part could be rendered without Angular and all the associated binding stuff. That's how the angularJS based games works (game rendering is not angular).

idea : If the render part is your bottleneck, then make a ReactJS component for this part only and connect it to your AngularJS interface. Best of both worlds :)

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.