Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context
When you run code inside an AngularJS Controller or Service object, you never have to worry about calling $apply() since your code is executing inside of an AngularJS "context." By this, I mean that AngularJS is aware that your code is running and will perform a dirty-check after the code has completed. When you're inside a Directive, however, AngularJS' view of the world is a bit more limited; it is the job of the Directive to call $apply() (or trigger $apply with something like $timeout) when AngularJS needs to be told about changes to the View Model (ie. $scope). Knowing when to do this, however, is a little tricky because some aspects of the Directive are actually executing inside of an AngularJS context.
If you've started to create your own Directives, there's little doubt that you've seen one of the following two messages:
- $apply is already in progress.
- $digest is already in progress.
These error messages indicate that you are trying to tell AngularJS about code that it already knows about. At best, you simply see this error in your console. At worst, this throws you into a recursive fiasco that crashes your browser (one of the very few downsides to using Firebug).
You're probably getting this error because you're inside of a Directive and you read that you have to tell AngularJS about all changes to the $scope that occurred inside of the Directive. And, while this philosophy is true, there are aspets of the Directive that AngularJS is already monitoring. Specifically, AngularJS already knows about:
- The link functions.
- The attribute $observe() handlers.
- The $scope $watch() handlers.
If you make changes to the $scope inside the synchronous execution of the link function, or the asynchronous execution of the $observe() and $watch() handlers, you're already executing inside of an AngularJS context. This means that AngularJS will perform a dirty-check after your code has run. And, AngularJS will trigger additional $digest life-cycles if necessary.
To demonstrate this, I have put together a small directive that watches for the load event on an image. If the image has already loaded by the time the directive executes, the load handler is called immediately - inside the AngularJS context. If the image has not loaded by the time the directive executes, the load handler is invoked asynchronously, and AngularJS needs to be made explicitly aware of the change.
NOTE: I have $observe() and $watch() handlers here simply for demonstration purposes - they don't actually do anything.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context
</title>
</head>
<body>
<h1>
Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context
</h1>
<ul ng-controller="ListController">
<!-- These images have dynamic SRC attributes. -->
<li ng-repeat="image in images">
<p>
Loaded: {{ image.complete }}
</p>
<img
ng-src="{{ image.source }}"
bn-load="imageLoaded( image )"
width="16"
height="16"
style="border: 1px solid #CCCCCC"
/>
</li>
<!-- This image has static SRC attribute. -->
<li>
<p>
Loaded: {{ staticImage.complete }}
</p>
<img
src="4.png"
bn-load="imageLoaded( staticImage )"
width="16"
height="16"
style="border: 1px solid #CCCCCC"
/>
</li>
</ul>
<!-- Load jQuery and AngularJS from the CDN. -->
<script
type="text/javascript"
src="//code.jquery.com/jquery-1.9.0.min.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
</script>
<script type="text/javascript">
// Create an application module for our demo.
var Demo = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the controller for the list of images.
Demo.controller(
"ListController",
function( $scope ) {
// I flag the given images as loaded.
$scope.imageLoaded = function( image ) {
image.complete = true;
};
// Set up the image collection. Since these images are
// all being loaded at DATA-URIs, they will be loaded
// immediately.
$scope.images = [
{
complete: false,
source: "1.png"
},
{
complete: false,
source: "2.png"
},
{
complete: false,
source: "3.png"
}
];
// I am the static image example.
$scope.staticImage = {
complete: false,
source: "4.png"
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I evaluate the given expression when the current image
// has loaded.
Demo.directive(
"bnLoad",
function() {
// I bind the DOM events to the scope.
function link( $scope, element, attributes ) {
// I evaluate the expression in the currently
// executing $digest - as such, there is no need
// to call $apply().
function handleLoadSync() {
logWithPhase( "handleLoad - Sync" );
$scope.$eval( attributes.bnLoad );
}
// I evaluate the expression and trigger a
// subsequent $digest in order to let AngularJS
// know that a change has taken place.
function handleLoadAsync() {
logWithPhase( "handleLoad - Async" );
$scope.$apply(
function() {
handleLoadSync();
}
);
}
// I log the given value with the current scope
// phase.
function logWithPhase( message ) {
console.log( message, ":", $scope.$$phase );
}
// -------------------------------------- //
// -------------------------------------- //
// Check to see if the image has already loaded.
// If the image was pulled out of the browser
// cache; or, it was loaded as a Data URI,
// then there will be no delay before complete.
if ( element[ 0 ].src && element[ 0 ].complete ) {
handleLoadSync();
// The image will be loaded at some point in the
// future (ie. asynchronous to link function).
} else {
element.on( "load.bnLoad", handleLoadAsync );
}
// For demonstration purposes, let's also listen
// for the attribute interpolation to see which
// phase the scope is in.
attributes.$observe(
"src",
function( srcAttribute ) {
logWithPhase( "$observe : " + srcAttribute );
}
);
// For demonstration purposes, let's also watch
// for changes in the image complete value. NOTE:
// the directive should NOT know about this model
// value; but, we are examining life cycles here.
$scope.$watch(
"( image || staticImage ).complete",
function( newValue ) {
logWithPhase( "$watch : " + newValue );
}
);
// -------------------------------------- //
// -------------------------------------- //
// When the scope is destroyed, clean up.
$scope.$on(
"$destroy",
function() {
element.off( "load.bnLoad" );
}
);
}
// Return the directive configuration.
return({
link: link,
restrict: "A"
});
}
);
</script>
</body>
</html>
As you can see, I'm loading three images dynamically (inside the ngRepeat) and one image statically. All four images monitor the load event using the bnLoad directive and log aspects of the directive's lifecycle.
The static image - 4.png - will already have been loaded by the time the directive executes. It will be the cause of the first load-handler to be executed in the following console output:
handleLoad - Sync : $apply
$observe : 4.png : $digest
$watch : true : $digest
$observe : 1.png : $digest
$watch : false : $digest
$observe : 2.png : $digest
$watch : false : $digest
$observe : 3.png : $digest
$watch : false : $digest
$observe : 1.png : $digest
$observe : 2.png : $digest
$observe : 3.png : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest
I know that it's probably difficult to make head or tails of this, so I'll point out a few key items:
The first handleLoadSync() call was triggered by the static image. Notice that it is already inside of the $apply phase of the AngularJS dirty-check lifecycle. This is because it was invoked within the link() function, which is already inside an AngularJS context.
All of the $observe() and $watch() handlers are inside of the $digest phase of the AngularJS dirty-check lifecycle. Once inside this, you will not need to tell AngularJS about any changes made to the $scope. AngularJS will automatically perform a dirty-check after each $digest.
All of the images that loaded asynchronously triggered the handleLoadAsync() method, which invoked the $apply() method in order to tell AngularJS about the change. This is why all of the subsequent handleLoadSync() methods are in the $apply phase - they were invoked from the handleLoadAsync() handler.
As I've said before, AngularJS directives are very powerful; but, they are also multi-faceted beasts and bit a of struggle to wrap you head around. Timing, in an AngularJS directive, is critical to its functionality, and one of the more difficult things to get right. Throw in something like CSS transitions - which only have partial browser support - and you'll quickly find yourself having $digest problems in one browser and not the other. Hopefully this exploration will help make the reason for these problems a bit more clear.
Want to use code from this post? Check out the license.
Reader Comments
I do not understand. The attribute called "bn-load". Is it special attribute? Is "bn-" and "ng-" some kind of special attributes? Yes, I understand that "ng-" means aNGularjs and should be used only by AngularJS Core and Plugins. Did I understand right? Returning to "bn-load", the directive called bnLoad. Hmm, is it because attributes can't have uppercase letters and if I put "-" sign before letter it allowed to name directive with uppercase? Hmm, I think I got ya.
My purpose is just to wait until all images loaded, do I need put $q.deffer on each image and then wait until all have been loaded? Any example please? Can't find information in the Web.
@Uelkfr,
Those prefixed attributes are "directives." The "ng-" ones are the ones that come natively with AngularJS; the "bn-" ones are the custom ones that I have created ("bn" stands for Ben Nadel).
When AngularJS takes control of part of a page, it compiles the templates and links the code. Part of this compilation is looking at those custom attributes to see if it needs to run special code that you provide. They are just a way of telling AngularJS, "Hey, run this code against that DOM node."
If you wanted to wait until all images were loaded, you could probably do something like this:
1. Create a root controller that can keep track of how many images there are and how many of them are loaded.
2. Create an "img" directive that can invoke methods on that "root" controller to add images and then tell the controller when those images have loaded.
Then, when the number of "known" images is the same as the number of "loaded" images, you know that all images have loaded in the given controller.
What are you going to do *after* the image have loaded?
Great post, thank you : )
@Alesei,
Glad you like it. Even after working with AngularJS for months, I still get a bunch of unexpected, "$digest is already in progress". So hard to debug sometimes!
@Ben,
bunch to learn indeed, but thats fun part : )
@Alesei,
100% agreed! Sometimes, there's nothing more fun that gaining a small bit of insight on some really complicated / unexpected corner of the framework. For example, I'm currently debugging some "force repaints" in my app that are due to the linking phase of some directive. In doing so, I'm trying to learn more about how Chrome Dev Tools works and using their timeline stuff:
http://www.smashingmagazine.com/2013/06/10/pinterest-paint-performance-case-study/
It's super exciting to think I may actually be able to solve my rendering time problems!!
@Ben,
Cool, i am not there yet, will be starting profiling soon though, so might need to reach out for advice : )
I ve built bunch of directives, but most of them were tag replacement directives, widgets. I haven't touched linking phase my self much. Now building attribute directives, so having few things to worry about.
@Alesei,
That's funny - I am on the exact other side - my directives are almost exclusively about linking - I've done very little when it comes to tag replacements and widgets :D
@Ben,
well you will get there ; ) at some point, i got to other side of the game eventually.
@Alesei,
I think the compile stuff hold a lot of power. I'll be looking into it in more, soon.
@All,
Something that I probably missed in this blog post - in Internet Explorer, an IMG is not considered "complete" if it has no SRC attribute / property:
www.bennadel.com/blog/2493-Testing-IMG-Complete-With-No-SRC-Attribute.htm
This will, of course, affect any directives that check "img.complete" in their linking phase.
@Ben thanks. Directives are powerful. So much flexibility and how you can use all these features.
Hey Ben,
I am writing a directive to dump a list on a page based on html data I retrieve async from the database server.
see below:
I thought that "MenuService.getMenuData().then" would wait for the data but some how the directive ends before the data arrives. I know I could put a timeout delay but that is not good. Do you have a suggestion as to what could be the problem?
TBApp.directive('tbnavMenu', function ($compile, MenuService) {
var tbTemplate = '<span>3empty</span>';
MenuService.getMenuData().then(function (val) {
tbTemplate = val;
});
var getTemplate = function () {
return tbTemplate;
}
var linker = function (scope, element, attrs) {
...
}
return {
restrict: "E",
rep1ace: true,
link: linker,
controller: function ($scope, $element) {
...
}
}
Hello, I have the same problem as @Luis.
I have my-directive which just loads template like this:
<div class="wrap" my-directive>
<div class="boxes" directive-handling-boxes>
<div class="box" ng-repeat="box in boxes">
....
</div>
</div>
</div>
in directive-handling-boxes is controller which loads some boxes data using factory function with $http(...).then(...) and puts them into $scope
there is also link function with function which should recount size of each box and apply changes to the template, but it runs before data load function and ng-repeat is processed, so i have to use $timeout(coutBoxSizes, 1000) to rerun this function to apply that changes..
It works fine, but i'm wondering if there is some better way to to this without $timeout, do you have any suggestions.
Thx
Am having same issues as Luis. Any insight will be helpful. Basically I am creating a recursive tree view. When I place the data in the scope it works perfectly but when I load data from an api and query it will not load fast enough and the directive will just assume the scope is empty. Anyone have ideas how to overcome this?
Great analysis, thanks for sharing. One thing I would do differently would be to actually create an isolate scope to pass in the expression for imageLoaded()
scope: {
imageLoaded: '&'
}
I typically like to see what my directive is affecting in the outside context. This isn't exactly related to the analysis, but just wanted to share the feedback.
Thanks!
@Larvosrolka, Did you solve your problem? I think you can use scope.watch inside your link function.