Link Function And $watch() Callback Timing In AngularJS Directives
Have you ever expected ngRepeat items to be on a page, but found that the given container is empty when you query it? If so, you're not alone. The timing of link functions and related $watch() callbacks, in AngularJS directives, can be a bit tricky; furthermore, the order of invocation can change depending on whether or not contextual directives are creating new child scopes.
Run this demo in my JavaScript Demos project on GitHub.
When AngularJS is used to compile HTML, the workflow typically looks something like this:
- compile - top-down.
- link - bottom-up.
- $digest - top-down (repeat until no dirty data).
First, the HTML gets compiled using a top-down, depth-first approach. This creates an aggregate linking function which, when called, links the compiled DOM (Document Object Model) from the bottom-up. During the linking phase, $watch() callbacks are registered on the contextual $scope. The $apply() method is then called (either implicitly or explicitly), in which a depth-first traversal of the Scope-tree is executed, triggering $watch() handlers when necessary.
Since $watch() callbacks are invoked during the $digest, we can deduce that the link functions and the $watch() callbacks get invoked in opposing order. Meaning, the link functions get called from the bottom-up and the $watch() handlers get called from the top-down (during the Scope-tree traversal).
Of course, it's not quite that simple (ha!). If link functions are invoked from the bottom-up, it means that $watch() callbacks are also defined from the bottom-up. Within a single Scope, $watch() callbacks are invoked in the same order in which they were defined. So, if nested directives all use the same Scope, it means that their $watch() callbacks will also be invoked bottom-up (since they were defined bottom-up), but asynchronously from the linking functions.
It's quite a mind-bend, so let's look at some code. In the following demo, I have three nested directives - Outer, Middle, Inner - that all provide a linking function and register a $watch() callback. You'll notice that each directive configuration object conditionally-defines a child scope based on the global variable - createNewChildScope:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Link Function And $watch() Callback Timing In AngularJS Directives
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Link Function And $watch() Callback Timing In AngularJS Directives
</h1>
<!-- Each of these directives logs its link() function and $watch() callback. -->
<div bn-outer>
<div bn-middle>
<div bn-inner>
Each one of these directives logs the link() function and the $watch()
function execution so you can see how scopes affect the order in which
different blocks of code will run.
</div>
</div>
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.6.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// This flag will determine if the following directives create a new CHILD SCOPE?
// Or, if they are all on the same scope. This will change the order in which the
// $watch() handlers fire.
var createNewChildScope = true;
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide a directive that logs its linking and watch functions.
app.directive(
"bnOuter",
function() {
// Return the directive configuration.
return({
link: link,
restrict: "A",
scope: createNewChildScope
});
// I bind the JavaScript events to the scope.
function link( scope, element, attributes ) {
console.log( "Link -- Outer" );
// NOTE: Watch handlers are invoked asynchronously.
scope.$watch(
"$id",
function handleWatchValueChange( newValue, oldValue ) {
console.log( "$watch() -- Outer:", newValue );
}
);
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide a directive that logs its linking and watch functions.
app.directive(
"bnMiddle",
function() {
// Return the directive configuration.
return({
link: link,
restrict: "A",
scope: createNewChildScope
});
// I bind the JavaScript events to the scope.
function link( scope, element, attributes ) {
console.log( "Link -- Middle" );
// NOTE: Watch handlers are invoked asynchronously.
scope.$watch(
"$id",
function handleWatchValueChange( newValue, oldValue ) {
console.log( "$watch() -- Middle:", newValue );
}
);
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide a directive that logs its linking and watch functions.
app.directive(
"bnInner",
function() {
// Return the directive configuration.
return({
link: link,
restrict: "A",
scope: createNewChildScope
});
// I bind the JavaScript events to the scope.
function link( scope, element, attributes ) {
console.log( "Link -- Inner" );
// NOTE: Watch handlers are invoked asynchronously.
scope.$watch(
"$id",
function handleWatchValueChange( newValue, oldValue ) {
console.log( "$watch() -- Inner:", newValue );
}
);
}
}
);
</script>
</body>
</html>
First, let's run this demo with the "createNewChildScope" set to TRUE. This will cause each directive to work with a newly-created Scope. When we run that code, we get the following output:
As you can see, when each of the nested directives creates a child scope, the link() function is invoked from the bottom-up and the relevant $watch() handlers are invoked from the top-down.
Now, let's run this demo again, but this time, we'll set "createNewChildScope" to FALSE. When we do that, we get the following output:
As you can see, this time, the link() functions and the $watch() callbacks were all invoked in the same order - bottom-up. Since none of the nested directives created a new scope, it means that they all registered $watch() handlers on the same scope - the $rootScope. As such, the $watch() handlers were registered in the same order as the link() function execution (bottom-up). And, when the $digest was performing its top-down Scope traversal, it coincidentally invoked the $watch() callbacks in the same order in which they were defined (bottom-up).
At the start of this post, I mentioned the ngRepeat directive. To understand how this all ties together, imagine that our inner directive was an ngRepeat and our outer directive was some "component" directive. If our middle directive is an "ngIf" directive - creating a new scope - then the ngRepeat elements will not have been rendered at the time the outer directive's $watch() callbacks are invoked. However, if both directives are working on same scope - if there is no ngIf - then the ngRepeat elements will be available at the time the module directive's $watch() callbacks are invoked.
This is some heady stuff. Of course, you could argue that it's all a moot point as one directive shouldn't care about the DOM elements generated by a descendant directive. And, philosophically, I'd probably agree with you. But, practically speaking, the need does arise; and when it does, understanding this link() function and $watch() callback timing can be very helpful.
Want to use code from this post? Check out the license.
Reader Comments