Refactoring bnRepeatSwitch To Use A Multi-Priority Directive In AngularJS
Yesterday, I demonstrated that a single directive could be bound to multiple priorities in the same compile and linking phase in AngularJS. While the coolness of this may not be obvious, I immediately thought of my previous post on the ngRepeat-optimized "switch" directive. It used two directives in order to execute on both "sides" of the native ngRepeat directive; but, given yesterday's discovery, I wanted to refactor bnRepeatSwitch to use a single directive with multiple priorities.
Run this demo in my JavaScript Demos project on GitHub.
I'll keep this blog post short since there's not really any new content - just some remixing of existing ideas. But, the basic idea behind the bnRepeatSwitch directive is that I wanted to pre-compile switch-case statements, that would be used in the context of an ngRepeat directive, such that they only had to be compiled once and not N-times (once for each ngRepeat clone). In order to do this, I need to access two different parts of the compilation and linking phase of the ngRepeat element:
- Pre-ngRepeat - pre-compile and strip-out "case" statements.
- ngRepeat executes.
- Post-ngRepeat - clone, link, and inject appropriate "case" statement.
In my previous attempt, I did this using two different directives:
- bnRepeatSwitch - executes pre-ngRepeat.
- bnRepeatSwitchOn - executes post-ngRepeat.
But, in the following code, you'll see that I have replaced the ngRepeatSwitchOn directive with an alternate version of the bnSwitchRepeat directive that executes at a lower priority. As such the following code now uses a single directive - bnSwitchRepeat - which is compiled and linked at two different priorities on the same element:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Refactoring bnRepeatSwitch To Use A Multi-Priority Directive In AngularJS
</title>
<style type="text/css">
a[ ng-click ] {
cursor: pointer ;
text-decoration: underline ;
}
.invitation {
color: #AAAAAA ;
font-style: italic ;
}
</style>
</head>
<body ng-controller="AppController">
<h1>
Refactoring bnRepeatSwitch To Use A Multi-Priority Directive In AngularJS
</h1>
<h2>
Your Team —
{{ users.length }} users and
{{ invitations.length }} outstanding invitations.
</h2>
<ul>
<!--
For each item, we are "switching" the rendering template based on the
"isInvitation" flag that was injected into each item. Note, however, that we
are NOT using the core ngSwitch directive - we are replacing it with a custom
directive that will pre-compile the "case" templates once and then simply
clone / link them during the ngRepeat rendering.
-->
<li
ng-repeat="person in people track by person.uid"
bn-repeat-switch="person.isInvitation"
switch-once="true">
<!-- BEGIN: Template for Invitation. -->
<div bn-repeat-switch-when="true" class="invitation">
{{ person.email }}
( <a ng-click>Resend</a> or <a ng-click>Cancel</a> invitation )
</div>
<!-- END: Template for Invitation. -->
<!-- BEGIN: Template for User. -->
<div bn-repeat-switch-when="false" class="user">
{{ person.name }} — {{ person.email }}
</div>
<!-- END: Template for User. -->
</li>
</ul>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.22.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 ) {
// Let's generate a large collection of invitations and users in such a
// way that they mix together nicely (rather than one group after the
// other). We need to have a large group since we are looking at
// performance, which typically becomes more obvious at larger sizes.
$scope.invitations = [];
$scope.users = [];
// Build each set programmatically.
for ( var i = 1 ; i < 1000 ; i++ ) {
$scope.invitations.push({
id: i,
email: ( "ben+" + i + "@bennadel.com" )
});
$scope.users.push({
id: i,
name: ( "ben+" + i ),
email: ( "ben+" + i + "@bennadel.com" )
});
}
// I hold the co-mingled collection of active users and pending
// invitations. Since this list is the aggregate of two different and
// unique sets of data, this collection has its own unique identifier
// - uid - injected into each item.
$scope.people = buildPeople( $scope.invitations, $scope.users );
// ---
// PRIVATE METHODS.
// ---
// I merge the given invitations and users collections into a single
// collection with unique ID (generated from the collection type and
// the ids of each item).
function buildPeople( invitations, users ) {
var people = sortPeople( invitations.concat( users ) );
for ( var i = 0 ; i < people.length ; i++ ) {
var person = people[ i ];
// I determine if the given item is an invitation or a user.
person.isInvitation = ! person.hasOwnProperty( "name" );
// I build the unique ID for the item in the merged collection.
person.uid = ( ( person.isInvitation ? "invitation-" : "user-" ) + person.id );
}
return( people );
}
// I sort the collection based on either name or email. Since I am
// sorting a mixed-collection, I am expecting not all elements to have
// a name; BUT, I am expecting all elements to have an email.
function sortPeople( people ) {
people.sort(
function comparisonOperator( a, b ) {
var nameA = ( a.name || a.email ).toLowerCase();
var nameB = ( b.name || b.email ).toLowerCase();
if ( nameA < nameB ) {
return( -1 );
} else if ( nameA > nameB ) {
return( 1 );
} else {
return( 0 );
}
}
);
return( people );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am a Switch/Case directive that is intended to work in conjunction with
// an ngRepat directive. I precompile the children tags with the attribute,
// "bn-repeat-switch-when (case/when) tags and then link them for each cloned
// item in the ngRepeat rendering. Currently, this directive will clear out the
// contents of the ngRepeat before rendering the appropriate "case" template.
app.directive(
"bnRepeatSwitch",
function( $parse, $compile ) {
// I provide a means to pass the switch expression, switch cases, and
// other settings from this "priority" of the directive to the next
// priority of the same directive.
function Controller() {
// I hold the parsed expression that each ngRepeat clone will have
// to $watch() in order to figure out which template to render.
this.expression = null;
// I hold the linking functions for each "case" template.
// --
// NOTE: I am using .create(null) here in order to make sure that
// this object does NOT inherit keys from any other prototype. This
// allows us to check keys on this object without having to worry
// about inheritance (supported in IE9+).
this.cases = Object.create( null );
// I determine if the switch-expression should be allowed to change
// once, or multiple times. If only once, the code is optimized to
// stop caring.
this.switchOnce = false;
}
// I compile the ngRepeat element (before ngRepeat executes) in order to
// pre-compile the rendering templates and prepare the linking functions.
function compile( tElement, tAttributes ) {
// Parse the expression that we want to watch/switch-on in each of
// the individual ngRepeat elements. By parsing it here, during the
// compilation phase, we save the overhead of having to parse it
// every time the ngRepeat elements is cloned and linked.
var switchExpression = $parse( tAttributes.bnRepeatSwitch );
// Find all the children elements that make up the "case" statements,
// strip them out, and compile them. This way, we can simply clone
// and link them later on.
// --
// NOTE: Since we are manually querying the DOM for these elements,
// we have to make assumptions about the format of the target
// attribute. We don't get to rely on the normalization AngularJS
// usually gives us (in the Attributes collection).
var switchCases = {};
// Check to see if the user only wants to check the switch expression
// once; this will allow the watchers to unbind after the first digest
// which should help with long-term performance of the page.
var switchOnce = ( tAttributes.switchOnce === "true" );
// Find, compile, and remove case-templates.
tElement
.children( "[ bn-repeat-switch-when ]" )
.remove()
.each(
function iterateOverCaseTemplates() {
var node = angular.element( this );
var caseValue = node.attr( "bn-repeat-switch-when" );
var caseLinkFunction = $compile( node.removeAttr( "bn-repeat-switch-when" ) );
switchCases[ caseValue ] = caseLinkFunction;
}
)
;
// Clear out any remaining content - the current state of this
// directive is not designed to use positional DOM insertion (the way
// that ngSwitchWhen does).
tElement.empty();
// Remove the switch-once attribute (if exists); we no longer need it
// and it will only add weight to the DOM at this point.
if ( "switchOnce" in tAttributes ) {
tElement.removeAttr( tAttributes.$attr.switchOnce );
}
// Clean up references that we no longer need.
tElement = null;
tAttributes = null;
// Return the linking function (which will run before the ngRepeat
// linking phase).
return( link );
// The primary purpose of this linking phase, for this directive,
// is to copy the switch expression and the case-link-functions into
// the current Controller so that they can be made available to the
// lower-priority version of this directive (which will link every
// time the ngRepeat element is cloned and linked).
function link( $scope, element, attributes, controller ) {
// Store the switch configuration.
controller.expression = switchExpression;
controller.cases = switchCases;
controller.switchOnce = switchOnce;
// Since our Controller is going to be passed-around, we should
// probably tear it down in order to help prevent unexpected
// memory references from sticking around.
$scope.$on(
"$destroy",
function handleDestroyEvent() {
controller.expression = null;
controller.cases = null;
controller.switchOnce = null;
controller = null;
// NOTE: We cannot tear-down compiled elements as those
// may be used in a subsequent linking phase.
}
);
}
}
// Return the directive configuration. Note that this "version" of the
// bnRepeatSwitch directive has to execute with priority 1,001 so that
// it is compiled BEFORE the ngRepeat is compiled and linked (at 1,000).
return({
compile: compile,
controller: Controller,
priority: 1001,
required: "bnRepeatSwitch",
restrict: "A"
});
}
);
// The first version of the bnRepeatSwitch directive runs before the ngRepeat
// directive has had a chance to transclude (compile) the content; now, that
// the element has been compiled, this version of the bnRepeatSwitch directive
// will run at a lower priority and will link AFTER the ngRepeat directive has
// been linkd (once for each cloned element).
app.directive(
"bnRepeatSwitch",
function() {
// After each ngRepeat element is cloned and linked I set up the watcher
// to render the appropriate template as the expression outcome changes.
function link( scope, element, attributes, switchController ) {
// Start watching the precompiled expression.
var stopWatching = scope.$watch(
switchController.expression,
function handleSwitchExpressionChange( newValue, oldValue ) {
// If the user does not expect the switch expression to ever
// change, then we can unbind the watcher after the watcher
// is configured.
if ( switchController.switchOnce ) {
stopWatching();
}
var linkFunction = switchController.cases[ newValue ];
// If there was no matching case statement template, just
// clear out the content.
if ( ! linkFunction ) {
return( element.empty() );
}
// If the expression outcome is changing, clear out the
// content before rendering the new template.
if ( newValue !== oldValue ) {
element.empty();
}
// Clone the case template and inject it into the DOM.
linkFunction(
scope,
function handleCaseTemplateCloneLinking( clone ) {
element.append( clone );
}
);
}
);
}
// Return the directives configuration. Require the Controller from the
// higher-priority version of the bnRepeatSwitch directive so that we can
// access the Expression and Case templates that were generated during
// the compile phase.
// --
// NOTE: This directive has to execute with the priority 999 so that it
// is linked AFTER the ngRepeat directive is linked (at 1,000).
return({
link: link,
priority: 999,
require: "bnRepeatSwitch",
restrict: "A"
});
}
);
</script>
</body>
</html>
The ngRepeat directive sticks out as the most obvious use-case for this kind of duality because of its 1-to-N nature: 1 compile phase, N linking phases. Thinking about the ngRepeat holistically, then, there are things that I'll want to do once, and then subsequent things that I'll want to do N-times. But, I'm sure there are many other use-cases. I mean, heck, the AngularJS source code uses this to implement some of its directives.
Want to use code from this post? Check out the license.
Reader Comments
Hm...in your comments you seem to imply that the higher a directive's priority the sooner it gets compiled **and** linked.
I just wanted to point out that the order* of each phase is as follows:
1. Compile (in descending priority order).
2. Instantiate controller (in descending priority order).
3. Pre-link (in descending priority order).
4. Post-link (in **ascending** priority order).
I.e. a higher priority means the directive will compile/controller/pre-link first, but post-link last.
**[Demo fiddle][1]**
--
<sub>
* Using templates affects the order, because some asynchronous operations are involved into resolving templates.
</sub>
[1]: http://jsfiddle.net/ExpertSystem/xyq90L7b/
@ExpertSystem,
Ah, good point! Though, I believe the caveat might be that I think the order was a breaking-change in AngualrJS 1.2. But, you are completely right.
And, to be honest, I don't think I've ever put much thought in to the order of the linking on a single element. I wonder what kind of use-cases would cause a conflict there.... hmmm.
@Ben,
I have no idea about pre 1.2.x, so you might be right.
Not sure if any use-case would cause a conflict (probably none).
The main points imo are two:
1.
The order doesn't seem to play a significant role and everything works fine although the link functions execute in the opposite order than you expected.
But, it is OK, because the second directive sets up a watch, so will pick up changes even if they happen later.
2.
Basically, you can probably achieve the same effect with just one directive executing at priority 1001, since you can use the compile/preLink functions to do stuff before ngRepeat and the postLink function to do stuff after ngRepeat.
(I say "probably" because I haven't tried it out, but I am pretty sure it is possible.)
@ExpertSystem,
You raise an interesting question. I don't actually understand the difference between the "link" function and the "postLink" function. I have only ever used "link. I just hopped over to the $compile() documentation:
### Pre-linking function
Executed before the child elements are linked. Not safe to do DOM transformation since the compiler linking function will fail to locate the correct elements for linking.
### Post-linking function
Executed after the child elements are linked. Note that child elements that contain templateUrl directives will not have been compiled and linked since they are waiting for their template to load asynchronously and their own compilation and linking has been suspended until that occurs. It is safe to do DOM transformation in the post-linking function on elements that are not waiting for their async templates to be resolved.
This doesn't necessarily clarify it. It refers to "child elements". So, does that include same-element directives at "lower priories". I'll have to do some more digging.
Thanks for bringing up such an interesting question!
The description of the `priority` property sheds some more light
(https://docs.angularjs.org/api/ng/service/$compile#-priority-):
> Directives with greater numerical priority are compiled first. Pre-link functions are also run in priority order, but post-link functions are run in reverse order.
(This is what I was referring to basically.)
---
What this means is that if ng-repeat has priority 1000 and your-directive has priority 1001 (both being on the same element), the order of execution will be as follows:
1. your-directive compile
2. ng-repeat compile
3. your-directive pre-linking
4. ng-repeat pre-linking
5. ng-repeat post-linking
6. your-directive post-linking
(Here is a fiddle that demonstrates this: http://jsfiddle.net/ExpertSystem/h0jjcr6g/1/)
@ExpertSystem,
I did a little bit more digging and just for point of clarity, I wanted to demonstrate (to myself) that the "post link" function and the "generic" link function are one-in-the-same things:
www.bennadel.com/blog/2746-the-post-link-function-is-the-link-function-in-angularjs-directives.htm
So, when we refer to Post and Pre link timing, the only "new" functionality we get with the breakdown is the "pre" portion. The "post" link function is just the standard link function (which links after its children have linked).
That said, going back to the compile and linking order, I still think that it won't work for ng-repeat and my Switch experiment. Remember, ngRepeat doesn't link "N-times". It only links once. It just so happens that within that one linking function, it clones itself and links the clone to a new child scope. And, since the priority of the ngRepeat is 1,000, no directives with higher priority (ex, 1,001) will link on the cloned element.
As such, I think that you still need to bind the bnRepeatSwitch at two different priorities - one that compiles before ngRepeat; and, one that links at a *lower priority* so that it will link during the transclusion.
Originally I didn't pay much attention to the fact that you need one directive to act on the template and one to act on the cloned instances.
I realize my suggestion won't work (because my proposed directive's postLink function will act on the original element (at that time replaced by ngRepeat with a comment)) and not on the cloned instances.
Sorry for causing a little confusion. Hopefully it was the constuctive type of confusion :D
---
That said, there is a part on the comment of the latter `bnRepeatSwitch`, that is kind of inaccurate/misleading (imo):
> NOTE: This directive has to execute with the priority 999 so that it is linked AFTER the ngRepeat directive is linked (at 1,000).
Normaly, a priority 999 would post-link **before** a priority 1000.
The real reason that a priority 999 is important here, is how `transclude: 'element'` works, which results in attaching directives with lower priority (e.g. < 1000 for ngRepeat) to the cloned instances and not the original element
---
As a final note, it **is** indeed possible to provide the same functionality using just one directive (relying on `ngRepeat`'s `transclude: 'element'` behaviour), but we need to change the implementation a bit. More specifically, we need to drop the use of controller (which acts as nothing more than a "medium" to pass some data from the compile phase to the linking phase).
We can define a `bnRepeatSwitch` directive at priority 999, which processes the templates during compiling and (after `ngRepeat` has transcluded the whole template element) and links the templates during the post-linking phase of each instance.
Here is the POC fiddle: http://jsfiddle.net/ExpertSystem/ovy60r25/5/