Defer DOM Tree Binding In AngularJS With Delayed Transclusion
When you start out with AngularJS, everything just "works." It's pretty freakin' awesome! But, as your applications get more complex, and your user interfaces (UIs) get more layered, the rendering of an AngularJS view can start to get slower. In particular, once you hit a critical mass of 2,000+ bindings [I'm told], processing time can become palpable. As such, I've been experimenting with ways to keep my UIs rich while keeping my processing time under control. My latest experiment involves delaying the binding of rarely-used DOM trees such that they don't have to be linked until they are needed.
View this demo in my JavaScript-Demos project on GitHub.
AngularJS already has ways to exclude portions of a page until they are needed; ngSwitch, ngSwitchWhen, and ngIf (formerly uiIf of the Angular-UI library) can all delay the linking of a given DOM tree. But, they all require a $watch() binding in order to determine when the detached DOM tree should be injected and linked. As such, you still have a one-binding per item hit.
My experiment is a little less elegant, a little more brute force, and a lot more coupled to a given context. Instead of creating AngularJS bindings that monitor the $scope, I'm using jQuery event delegation to perform a one-time, just-in-time DOM tree transclusion.
This approach doesn't watch the $scope in order to inject and subsequently detach a DOM tree; rather, it waits until the DOM tree is first needed and then, it injects it, permanently. It doesn't watch to see if the DOM tree should be excluded - it just leaves it in place going forward.
To demonstrate this, I have a large list of friends, each of which has a "delete confirmation" overlay. The overlay itself, however, is ripped out of the DOM during the compilation process; then, it is cloned, transcluded, and injected on an as-needed basis.
<!doctype html>
<html ng-app="Demo" ng-controller="AppController">
<head>
<meta charset="utf-8" />
<title>
Defer DOM Tree Binding In AngularJS With Delayed Transclusion
</title>
<link rel="stylesheet" type="text/css" href="demo.css"></link>
</head>
<body>
<h1>
Defer DOM Tree Binding In AngularJS With Delayed Transclusion
</h1>
<ul bn-list class="items">
<li ng-repeat="friend in friends" class="item">
<span class="name">{{ friend.name }}</span>
<a ng-click="showConfirmation( friend )" class="delete">Delete</a>
<div
ng-show="friend.isShowingConfirmation"
class="deleteConfirmation">
<span class="confirmation">
<span class="intent">Delete</span>
<span class="target">{{ friend.name }}</span>
</span>
<a ng-click="hideConfirmation( friend )" class="action">Delete</a>
<a ng-click="hideConfirmation( friend )" class="action">Cancel</a>
</div>
</li>
</ul>
<!-- Load jQuery and AngularJS. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// Define the root-level controller for the application.
app.controller(
"AppController",
function( $scope ) {
// Build up a large list of friends.
$scope.friends = buildFriends( 1000 );
// ---
// PUBLIC METHODS.
// ---
// I show the delete confirmation for the given friend.
$scope.showConfirmation = function( friend ) {
// Convert the name to uppecase so we can see the
// change reflected in both the item and the
// delete confirmation overlay.
friend.name = friend.name.toUpperCase();
friend.isShowingConfirmation = true;
};
// I hide the delete confirmation for the given friend.
$scope.hideConfirmation = function( friend ) {
friend.isShowingConfirmation = false;
};
// ---
// PRIVATE METHODS.
// ---
// I build a collection of friends with the given size.
function buildFriends( count ) {
var names = [ "Sarah", "Joanna", "Tricia" ];
var friends = [];
for ( var i = 0 ; i < count ; i++ ) {
friends.push({
id: i,
name: names[ i % 3 ],
isShowingConfirmation: false
});
}
return( friends );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I help the rendering of the DOM tree such that it can
// delay the linking of parts of the DOM tree that are not
// often used.
app.directive(
"bnList",
function( $compile ) {
// I compile the DOM template.
function compile( tElement, tAttributes ) {
// When prepareing the template for the list, we
// want to extract the "delete confirmation" stuff
// since it's not going to be used very often.
var tOverlay = tElement.find( "div.deleteConfirmation" )
.remove()
;
// Now that we've extracted the overlay, we need
// to compile it separeately so that it can be
// transcluded an linked separately.
var transcludeOverlay = $compile( tOverlay );
// I bind the UI to the scope.
function link( $scope, element, attributes ) {
// For this demo, we know that the delete
// confirmation is triggered when the user
// goes to click on the delete link. As such,
// we can inject the delete confirmation overlay
// when the user "starts" to click.
element.on(
"mousedown",
"li a.delete",
function( event ) {
var item = $( this ).closest( "li" );
var localScope = item.scope();
// Check to see if the item has already
// been injected. If so, ignore click.
if ( localScope.hasOwnProperty( "__injected" ) ) {
return;
}
// Transclude and link the DOM tree for
// the delete confirmation.
transcludeOverlay(
localScope,
function( overlayClone, $scope ) {
item.append( overlayClone );
$scope.__injected = true;
}
);
// Trigger a $digest so all the watchers
// within the injected DOM tree know to
// initialize their bindings.
localScope.$apply();
}
);
}
return( link );
}
// Return the directive configuration.
return({
compile: compile
});
}
);
</script>
</body>
</html>
Since the "delete confirmation" DOM tree is removed from the view template during compilation, none of its components are bound. Furthermore, none of the ngRepeat instances have active bindings for the delete confirmation, since there is no delete confirmation in the cloned and transcluded ngRepeat item.
As the delete confirmation is needed (as indicated by the mouse-down event, outside of the AngularJS context), the delete confirmation DOM tree is cloned and transcluded into the appropriate Friend item. This way, it is present when the "click" event takes place and the showConfirmation() $scope method is invoked.
Clearly, this approach is tightly bound to a given context in which the appropriate triggers are known ahead of time. That said, this approach is an optimization (or an attempt at optimization) that would necessarily be bound to given context. Of course, I think I can come up with ways to make it slightly more generic; but not too much. Anyway, I thought this was a pretty interesting experiment. And, if nothing else, has taught me more about how transclusion works in AngularJS.
Want to use code from this post? Check out the license.
Reader Comments
hi Ben,
can you implementing how about if user click Delete on deleteConfirmation?
should delete friend item right?
@Hengkiardo,
Sorry for not responding sooner - work has been hell the last few weeks. But, yes, if the user clicks the delete in the confirmation "overlay", it will act on the correct Friend instance. You can confirm this is true because the body of the delete/cancel handlers update the appropriate Friend instance:
friend.isShowingConfirmation = true/false;
This is what allows the correct overlay to be closed, which indicates the correct Friend instance.
Made into a directive for more reusability!
http://embed.plnkr.co/n8Pi9w1PxrVOt2GQRFWd/preview
Hi Ben, Nice post.
Why is this code required ?
// Check to see if the item has already
// been injected. If so, ignore click.
if ( localScope.hasOwnProperty( "__injected" ) ) {
return;
}
Why not remove the mousedown event in the bnList after its first use? Since it will never be removed again and that would free up memory?
I am building a large app with an accordion style list with a nested ng-repeat for items in the individual accordions.
I heard you on the Angular in action podcast saying you could stash the removed DOM elements so you could remove and re-add them over and over.
Do you have any advice on how to destroy the added element and re-add it when it's requested again?