Creating An Optimized Switch Directive For Use With ngRepeat In AngularJS
Yesterday, I looked at how to use a single ngRepeat directive, in AngularJS, to render a list of commingled datasets. In that approach, I used the ngSwitch and ngSwitchWhen directives. While this worked perfectly well, I was concerned that it was causing much of the DOM (Document Object Model) to be compiled twice - once for the ngRepeat and once for each ngSwitchWhen. As such, I wanted to take a stab at creating a "switch" directive that was optimized to work specifically with ngRepeat and commingled data.
Run this demo in my JavaScript Demos project on GitHub.
My concern with the vanilla ngSwitch / ngSwitchWhen approach is that ngSwitchWhen uses "transclude:element" in its directive configuration. I believe (though I am not 100% sure) that this means that each ngSwitchWhen directive will be re-compiled every time the parent ngRepeat element is cloned and linked. To me, this seems unnecessary for the given context since we already know that the ngRepeat element is going to be compiled.
To get around this, what I'd like is precompile the case/when templates before the ngRepeat directive runs. Then, as each ngRepeat clone is being added to the DOM, we clone and link the appropriate rendering template. The real complexity here is that we need access to two different phases of the directive lifecycle - before ngRepeat and after ngRepeat.
I don't believe that this behavior can be achieved in a single directive. So, what I've done in the following code is to do the precompiling in a pre-ngRepeat directive - bnRepeatSwitch; but, then to have that directive inject a different, post-ngRepeat directive - bnRepeatSwitchOn - which will take care of the per-item linking and template selection.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Creating An Optimized Switch Directive For Use With ngRepeat 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>
Creating An Optimized Switch Directive For Use With ngRepeat In AngularJS
</h1>
<h2>
Your Team —
{{ users.length }} users and
{{ invitations.length }} outstanding invitations.
</h2>
<ul>
<!--
When we use the ngRepeat directive, notice that we are tracking the DOM/object
relationship using the UID that was derived during the list merge. This allows
us to make sure we don't destroy and re-build DOM elements when we start to
introduce Cached vs. Live data.
Then, 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">
<!-- 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 and the switch cases
// from this directive into the subsequent "linking" 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.
this.cases = Object.create( null );
}
// 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.
var switchCases = {};
// 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();
// Since this directive is compiling and linking BEFORE the ngRepeat,
// it means that it will only be linked once (for this ngRepeat).
// However, in order for this workflow to work, we need to have
// access to the linking phase of EACH ngRepeat item. As such, we
// have to inject another directive with a lower priority that will
// compile/link AFTER the ngRepeat directive.
// --
// NOTE: If this ngRepeat is contained INSIDE another directive that
// does transclusion, then THIS directive may be linked more than
// once. But, it will only be linked once per sibling ngRepeat.
tElement.attr( "bn-repeat-switch-on", "" );
// Strip out the old attribute just to make the DOM a little smaller.
// Now that we have compiled the expression, we no longer need access
// to it on the DOM.
tElement.removeAttr( tAttributes.$attr.bnRepeatSwitch );
// Clean up references that we no longer need.
tElement = null;
tAttributes = null;
// 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
// subsequent "On" directive linking phase.
function link( $scope, element, attributes, controller ) {
// Store the expressions.
controller.expression = switchExpression;
controller.cases = switchCases;
// 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 = null;
}
);
}
// Return the linking function (which will run before the ngRepeat
// linking phase).
return( link );
}
// Return the directive configuration. Note that this 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"
});
}
);
// I am the sister directive for "bnRepeatSwitch". The bnRepeatSwitch directive
// compiles and links BEFORE the ngRepat directive. My job is to then compile
// and link AFTER the ngRepeat directive such that I am linked for each clone
// that the ngRepeat directives creates.
app.directive(
"bnRepeatSwitchOn",
function() {
// After each ngRepeat element is cloned, 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.
$scope.$watch(
switchController.expression,
function handleSwitchExpressionChange( newValue, oldValue ) {
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 for the
// "switch" 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>
There's definitely more code here since we have to define new directives; but, if you look at the HTML, there's hardly any difference at all. So, is the added complexity worthwhile? It's hard to say exactly, especially with isolated testing. But, when I look at Chrome Dev Tools, I do see significantly faster rendering times and significantly lower memory usage.
NOTE: I demonstrate the performance and memory benefits of this approach in the video.
This approach isn't quite as flexible as the ngSwitch/ngSwitchWhen approach since my directive clears out the entire ngRepeat content before rendering the selected template. But, that's more a factor of laziness in my implementation rather than a limitation of the approach itself. And, in my experience, these kinds of optimizations do add up and lead to a faster, more responsive user experience as the size of your AngularJS application grows.
Want to use code from this post? Check out the license.
Reader Comments
hi,
its great article, thanks.
but its working with one ng-repeat,
does compile function not run again for second ng-repeat? how can i fix it.
@Tolga,
actually, i wrote a little bit wrong :(
when using bnRepeatSwitch on ng-repeat that is in another ng-repeat, switchCases property can not read...
for example;
<ul ng-repeat="people in peopleArray">
<li ng-repeat="person in people"
bn-repeat-switch="person.isInvitation">
.
.
.
</li>
</ul>
@Tolga,
I don't _think_ it should matter since the inner ngRepeat element will be compiled and linked when the inner ngRepeat directive is compiled and linked. I'll have to try this out on my end to see if I can figure out why it wouldn't be working.
@Tolga,
Hmm, you are right. If you use this inside another ngRepeat, it will work for the first ngRepeat and then break on the second ngRepeat. I think it has to do with when things are getting compiled. Having two nested things with "terminal" compilation is hurting my head :D I will have to sit and think on this a bit more. Hopefully will have a better answer tomorrow.
@Tolga,
After some quick tinkering I believe it has to do with the fact that my directive is actually being compiled once (for the outer ngRepeat transclusion), but then linked twice (one for each outer ngRepeat item). I will have to sit and tinker before I can figure out the best approach.
Thanks for bringing this to my attention!!
@Tolga,
I think I figured it out. The directive gets compiled once. Then, a new controller is created for each linking phase. I'll have something working later tonight.
@Tolga,
Thanks again for your help in pointing out this problem. I have updated the code to fix the nested transclusion error. I've also created a "test" page that demonstrates the usage inside a nested ng-repeat:
http://bennadel.github.io/JavaScript-Demos/demos/ng-repeat-switch-angularjs/tolga.htm
I hope that helps.
thanks m8,
ur articles are rock... thank for sharing...
I am "just starting out" with AngularJS --- I always have liked your way of expression/learning , so I am curious if you can direct me down the right path to learning this technology - The right way, from the get go and something that doesn't go too slow. Any advice?
Hi! Great article.
It really helped me solve a issue I was having.
Thanks a lot man!