When Do You Need To Compile A Directive In AngularJS
On the most recent episode of Adventures in Angular, on Advanced Directives, Ward Bell and Joe Eames posed a very provocative question - When do you actually need to use the compile phase of an AngularJS directive? It used to be that you needed the compile in order to access the transclude function; but, as of AngularJS 1.2 that's no longer the case (the transclude function is now exposed in the linking phase). As such, does the compile step still have a purpose?
Run this demo in my JavaScript Demos project on GitHub.
Since I listened to that podcast, this question has tickled my mind. I know that I do sometimes use the compile phase in my directives; but, I was having a lot of trouble finding the common theme - the underlying requirement. I kept coming back to a thought that was so simple that it didn't sound right:
I use the compile() phase when I need to alter the structure of the DOM (Document Object Model).
But, in a way, this isn't right either - I can, and do, alter the DOM within the linking phase as well, typically based on user interaction or view-model state. So, I think I need to clarify the context of the alteration:
I use the compile() phase when I need to alter the structure of the DOM (Document Object Model) in a way that is based on nothing but the existing structure of DOM.
While this isn't a perfect definition, I think it gets closer to the heart of the matter.
Now, sometimes, the difference between the compile() phase and the link() phase is a very thin, very fuzzy line. There are many times in which DOM alterations made during the link() phase have the exact same outcome as DOM alterations made during the compile() phase. In those cases, it can be unclear as why you should choose compile() over link().
It helps to think about AngularJS outside of your directive. The compile and linking phases don't just apply to your directive - they apply to the DOM tree at large. Furthermore, the compiling of your directive is very likely to be part of a larger compile phase that started higher up in the DOM tree. When you think about it this way, it can help to clarify why you would want to use the compile() phase over the linking phase().
To explore this, let's look at a very small example in which the outward expression of two directives are the same; but, the underlying implementation is different. The most simple use-case that I could think of is a directive that does nothing but adds a CSS class to its element:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
When Do You Need To Compile A Directive In AngularJS
</title>
<style type="text/css">
.highlight {
background-color: yellow ;
display: inline-block ;
margin-right: 10px ;
}
.emergency {
border: 1px solid red ;
color: red ;
display: inline-block ;
margin-right: 10px ;
padding: 4px 10px 3px 10px ;
}
</style>
</head>
<body>
<h1>
When Do You Need To Compile A Directive In AngularJS
</h1>
<p>
<!-- Each of these custom directives adds a class to the SPAN element. -->
<span
ng-repeat="word in [ 'Word', 'to', 'your', 'mother' ]"
bn-highlight
bn-emergency>
{{ word }}
</span>
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.13.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// In this directive, we're going to add a class to the element, but we're
// going to do this during the COMPILE step to see how often that has to happen.
app.directive(
"bnHighlight",
function() {
// Return the directive configuration.
return({
compile: compile,
restrict: "A"
});
// I compile the DOM before the linking phase.
function compile( tElement, tAttributes ) {
console.log( "Adding .highlight class." );
tElement.addClass( "highlight" );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// In this directive, we're going to add a class to the element; however, this
// time, we're going to do that during the LINKING phase rather than in the
// compiling phase.
app.directive(
"bnEmergency",
function() {
// Return the directive configuration.
return({
link: link,
restrict: "A"
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes ) {
console.log( "Adding .emergency class." );
element.addClass( "emergency" );
}
}
);
</script>
</body>
</html>
I know this is a trite example, but I'm trying to remove all the noise and get down to the pure philosophy of the compile phase. When you have to add a CSS class to an element, you can absolutely do that during the linking phase. And, in fact, when we run the above code, we can see that both directives added their respective CSS class quite nicely. But, the console tells a more interesting story than the UI (user interface):
Clearly, both directives added their respective CSS class. But, when we look at the console output, we can see that bnHighlight directive only needed to perform this operation once - during the compile phase. bnEmergency, on the other hand, had to do this four times - once for each linking phase of the parent repeater.
This is why it's important to think about your directive in the context of the AngularJS DOM processing workflow. I'm not saying you should have to couple your beautifully-isolated component to the rest of the page - what I'm saying is that your component is already coupled to the AngularJS framework; and, as such, that should give you perspective on what code goes where.
Now, in the above example, you can brush-off the compile vs. link conversation as a mere performance optimization. After all, they both have the same outcome, only the compile-oriented one is the tiniest tiniest tiniest bit faster. But, when it comes to altering the DOM, a more advanced directive can demonstrate that the compile phase isn't faster, it's critical.
In this example, we're going to create the "bones" of an HTML Menu component directive. Think of this like a custom "Select" element in which the user can provide custom HTML for the options that get displayed in the menu. In order for this to work, and be styled properly, our component directive needs to alter the DOM of the user-provided content so that it has the appropriate CSS hooks. Not only do these hooks create a consistent visual experience, they also become critical when we have to start responding to user interaction based on arbitrary target nodes (not showin in this demo - try looking at this blog post).
In the following code, I try to recreate the same effect using two different component directives. One uses both the compile phase and the link phase, whereas the other directive attempts to move all DOM alteration into the link phase:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
When Do You Need To Compile A Directive In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
When Do You Need To Compile A Directive In AngularJS
</h1>
<!--
In this component directive, we are going to both COMPILE the transcluded
content and LINK it.
-->
<div bn-menu>
<ul>
<li>
Heather <em>(static)</em>
</li>
<li>
Kim <em>(static)</em>
</li>
<li ng-repeat="friend in [ 'Joanna', 'Sarah' ]">
{{ friend }} <em>(ng-repeat)</em>
</li>
</ul>
</div>
<!--
In this component directive, we are going to try and do everything that we
need to do in the LINK function only.
-->
<div bn-menu-fail>
<ul>
<li>
Heather <em>(static)</em>
</li>
<li>
Kim <em>(static)</em>
</li>
<li ng-repeat="friend in [ 'Joanna', 'Sarah' ]">
{{ friend }} <em>(ng-repeat)</em>
</li>
</ul>
</div>
<!-- This is the template that we are going to use for our module directive. -->
<script type="text/ng-template" id="module.htm">
<div class="menu-root">
<div class="root-option-label option-label">
( Menu Root - Dynamic )
</div>
</div>
<!-- Original content to be transcluded here. -->
</script>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.13.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// When a directive uses a TEMPLATE, the compile function will accept the
// content of that template. But, in this case, what we actually want to do
// is compile the transcluded content (pre-transclusion). As such, we need to
// define this directive at two priorities, with this one executing before the
// transclude one.
app.directive(
"bnMenu",
function() {
// Return the directive configuration. Note that we are executing at
// priority 1 so that we execute before the transclude version.
return({
compile: compile,
priority: 1
});
// I compile the directive element prior to trnasclusion. I add the
// necessary classes to the transcluded content for the CSS hooks and
// (not shown in this demo) the JavaScript event hooks (that will use
// event delegation based on event-target / CSS class).
function compile( tElement, tAttibutes ) {
tElement.addClass( "m-menu" );
var optionsList = tElement
.children( "ul" )
.addClass( "menu-options" )
;
var options = optionsList
.children( "li" )
.addClass( "menu-option option-label" )
;
}
}
);
// Since this directive uses a TEMPLATE (url), any attempt to call the compile()
// method will only give us access to the template content, not the transcluded
// content. As such, this priority will only take care of transcluding the
// content into the template.
app.directive(
"bnMenu",
function() {
// Return the directive configuration.
// --
// NOTE: Priority defaults to 0. I'm including it here for explicitness.
return({
link: link,
priority: 0,
templateUrl: "module.htm",
transclude: true
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes, _controller, transclude ) {
// Link and transclude the user-defined content into our component.
transclude(
function( userContent ) {
element.append( userContent );
}
);
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// In this version of the menu component, we're going to forego the COMPILE step
// and try to do everything in the LINK function.
app.directive(
"bnMenuFail",
function() {
// Return the directive configuration.
return({
link: link,
templateUrl: "module.htm",
transclude: true
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes, _controller, transclude ) {
element.addClass( "m-menu" );
// Link and transclude the user-defined content into our component.
transclude(
function( userContent ) {
var optionsList = userContent
.eq( 1 )
.addClass( "menu-options" )
;
var options = optionsList
.children( "li" )
.addClass( "menu-option option-label" )
;
element.append( userContent );
}
);
}
}
);
</script>
</body>
</html>
As you can see, both directives attempt to add various CSS hooks to the transcluded, user-provided content for the component directive. However, when we run this code, we get the following output:
As you can see, the functionality provided by the compile phase in the first directive cannot be [cleanly] reproduced by the linking phase in the second directive. The reason for this is that the ngRepeat directive has already compiled its repeater and has ripped it out of the DOM (to be re-inserted during a $watchCollection() callback). As such, those last two repeat-elements are physically unavailable during the linking function of the second directive.
But, specifics aside, the underlying flaw of the second directive is that it made inappropriate DOM alterations in the linking phase. Going back to my original answer as to "why compile," the CSS hooks needed to be added - the DOM needed to be altered - based on nothing but the existing structure of the DOM. As such, it should have been done in the compile phase, not the linking phase.
With all that said, I readily admit that I rarely use the compile function of an AngularJS directive. But from time to time, I do. And, for me, the true purpose of the compile phase - altering the structure of the DOM - remains consistent, despite recent changes in transclude function visibility.
Want to use code from this post? Check out the license.
Reader Comments
Thanks, nice post
Great write up yet again, thanks Ben.
This is really a helpful post. Kudos to this sparkling parse of compilation and distinguishing between compile and link.
Good post to understand compile. I have a similar scenario where I need to add a class only to certain child elements and not all of them. Since I cannot access scope from compile, how do I ensure that the class is added only to certain elements(say with a specific ID) alone?
Greetings,
You are the best writer and thinker i ever met.
great post
Hi Ben,
in case of bnMenuFail, since transclude is set to true, the order of execution for the directives should be
1) compile ng-repeat
2) compile bnMenuFail
3) link ng-repeat
4) link bnMenuFail
In this case, since ng-repeat link runs before bnMenuFail link, the DOM elements for ng-repeat that were taken out should have been attached back to the DOM during ng-repeat's link phase. This would mean link of bnMenuFail should have access to those elements by the time it executes.
Could you please explain a bit more on what exactly is happening in the compile and link phase of both these directives?
Thanks
This is really nice post. It really helped me in understating the logic and this is not first time , I keep reading your post and I stop by anytime It comes to my sight.
Thanks for your effort .
I am having trouble with compile, I believe.
I have a Rails TemplatesGenerator < Rails::Generators::Base
I am using it to generate a template with an AngularJS directive,
ng-bind-html.
The problem is that the element.html file which is parsed to generate the
template, and built with Thor template action, is triggering the AngularJS
directive to be compiled, and thus my final template has had the ng-bind-html directive yanked out, and {{ ingredient('body').value }} inserted as content.
This is what I want:
<div ng-bind-html="safe(ingredient('body').value") class="body"></div>
This is what I am getting:
<div class="body">{{ingredient('body').value}}</div>
Do you know if there is any way to stop the compile from happening?
If not, what kind of approach could I use to go about fixing this in my Angular directive to?
I am calling 'safe' to escape with $sce.trustAsHtml --- that is the reason for approach I have taken. ingredient('body').value is Richtext
This problem actually due to the gem I was using not being
updated as it should of been to be compatible with another
gem that it worked with, alchemy_cms.
The broken gem was alchemy-angular, and I have submitted a PR
to fix it.