Skip to main content
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Shawn Slaughter and Scott Stroz
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Shawn Slaughter Scott Stroz ( @boyzoid )

Workflow Differences Between $scope.$watch() and Attributes.$observe() In AngularJS

By on

Yesterday, I spent about an hour trying to figure out why my attributes.$observe() callback wasn't being invoked in my transcluded directive in AngularJS. After much digging, it turned out to be a difference in the way the two types of callbacks are registered. And, to make it more fun, the behavior of these workflows changes in subtle ways between AngularJS 1.0.x and 1.2.x.

The goal of my directive was to compile and then transclude (and inject) a subtree of the DOM (Document Object Model) at a later point in time. Since this was an asynchronous action, it took place outside of the normal AngularJS "context;" this means that I had to tell AngularJS that the change took place after the DOM was modified.

Here's a paired-down example - notice that my "outer directive" is dynamically compiling and transcluding my "inner directive":

<!doctype html>
<html ng-app="Demo" ng-controller="AppController">
<head>
	<meta charset="utf-8" />

	<title>
		Workflow Differences Between $scope.$watch() and Attributes.$observe() In AngularJS
	</title>
</head>
<body>

	<h1>
		Workflow Differences Between $scope.$watch() and Attributes.$observe() In AngularJS
	</h1>

	<p bn-outer>
		<!-- Inner directive will be injected dynamically. -->
	</p>

	<!-- Load jQuery and AngularJS. -->
	<script type="text/javascript" src="../../vendor/jquery/jquery-2.0.3.min.js"></script>
	<script
		type="text/javascript"
		src="../../vendor/angularjs/angular-1.0.7.min.js"
		disabled-src="../../vendor/angularjs/angular-1.2.16.js"
	></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		var app = angular.module( "Demo", [] );


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// Define the root-level controller for the application.
		app.controller(
			"AppController",
			function( $scope ) {

				$scope.foo = "bar";

			}
		);


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// I compile and inject an instance of another directive at a later time.
		app.directive(
			"bnOuter",
			function( $compile ) {

				// I bind the UI events to the local scope.
				function link( $scope, element, attributes ) {

					var transclude = $compile( "<span bn-inner='test'>Woot</span>" );

					// After a brief timeout, clone and inject the compiled DOM element.
					setTimeout(
						function() {

							transclude(
								$scope,
								function( clone ) {

									element.append( clone );

								}
							);

							// Tell AngularJS that a change has occurred (this will
							// invoke various $watch() callbacks).
							$scope.$digest();

						},
						250
					);

				}

				// Return the directive configuration.
				return({
					link: link,
					scope: true
				});

			}
		);


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// I am here to demonstate which callbacks get invoked.
		app.directive(
			"bnInner",
			function( $compile ) {

				// I bind the UI events to the local scope.
				function link( $scope, element, attributes ) {

					// Register an $observe callback.
					attributes.$observe(
						"bnInner",
						function innerObserveFunction() {

							console.log( "Inner $observe() fired." );

						}
					);

					// Register a $watch callback.
					$scope.$watch(
						function innerWatchFunction() {

							console.log( "Inner $watch() fired." );

						}
					);

				}


				// Return the directive configuration.
				return({
					link: link,
					restrict: "A"
				});

			}
		);

	</script>

</body>
</html>

At the time, I was using the $scope.$digest() method as a performance optimization. The $scope.$digest() approach worked fine for the $scope.$watch() callbacks; but, it didn't trigger any of the attributes.$observe() callbacks. I fixed this by switching from a $scope.$digest() method call to a $scope.$apply() method call. But, fixes aside, I was curious as to why one set of callbacks worked while the other didn't.

After digging through the AngularJS source code, I narrowed it down to the workflow in which the various callbacks are stored. When you register a $scope.$watch() callback, it gets stored in scope.$$watchers. This means that each element in the AngularJS $scope chain gets its own local set of watch callbacks.

When you register an attributes.$observe() callback, it gets stored in an instance of the Attributes "class," which gets injected into then Interpolate directive, which registers a $watch() on the interpolated value. This $watch(), on the interpolated value, also gets stored in the scope.$$Watchers collection of the scope associated with the current element.

But, it's not quite that simple (he says sarcastically). The $watch() on the Attribute only gets configured if AngularJS sees that the attribute value uses interpolation. If the attribute is static (ie, it doesn't use {{expression}} notation), AngularJS won't bother setting up a $watch() callback since the value will never change and the callback will never be called.

So, if we have a static attribute value, how does the attribute.$observe() callback ever get invoked? Herein lies the issue; when the attributes.$observe() callback is registered, AngularJS adds an invocation of the callback to the $$asyncQueue using:

$rootScope.$evalAsync( ... invoke your $observe callback ... );

This poses a number of challenges that all have to do with changes made to AngularJS after 1.0.8 (which is what I am currently using in production).

First, in AngularJS 1.0.8, each instance of Scope has its own $$asyncQueue. This means that when I go to call $scope.$digest(), I won't trigger the $observe() callback since it's stored in the $rootScope, which is higher up in the Scope chain.

NOTE: In AngularJS 1.2, there is only one $$asyncQueue, on the $rootScope, which is shared by all instances of Scope. This means that calls to $evalAsync(), in AngularJS 1.2, will cause a full-scope-chain $digest phase.

Second, in AngularJS 1.0.8, $scope.$evalAsync() will not add a deferred "fail safe." In AngularJS 1.2, $scope.$evalAsync() will use $browser.defer() - setTimeout - in order to make sure that the asynchronous item actually gets evaluated. As of 1.0.8, this was not the case; this means that in 1.0.8, if you're outside of a $digest phase, your async item won't get invoked until the next $digest, which may never happen (depending on your situation).

So, going back to my original fix, changing from $scope.$digest() to $scope.$apply() - after the transclusion - worked because the $apply() triggered a $rootScope $digest phase, which cleared the $$asyncQueue in the $rootScope, which is where the attributes.$observe() callback was waiting for invocation.

If you're on a current version of AngularJS (1.2.x), this post holds little practical value for you. But, since I'm still using 1.0.8 in production, these kinds of changes are very important to understand. These nuances affect the optimizations that I can make, which has a very real impact on the user experience of complex interfaces (with loads of data).

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel