Preloading Data Before Executing ngInclude In AngularJS
Earlier this week, I looked at loading AngularJS components with RequireJS after your application had been bootstrapped. While interesting, I disliked the fact that my calling code needed to know about the lazy-loading behavior. What I really wanted to do was centralize the lazy-loading around the part of the app that actually required it. And, since I use ngSwitch, ngSwitchWhen, and ngInclude to manage my nested views, what I really wanted to do was defer lazy-loading to the use of the relevant ngInclude directives.
View this demo in my JavaScript-Demos project on GitHub.
While this is more of an exploration in understanding how compilation and transclusion works in AngularJS, I envisioned a ngSwitchWhen directive that looked something like this:
<div
ng-switch-when="module"
bn-preload="module-data"
ng-include=" 'module.htm' ">
</div>
In this case, the "bnPreload" directive would lazy-load AngularJS components before allowing the ngInclude directive to make the subsequent HTTP request and update the DOM. However, it would only do this when the ngSwitchWhen directive matched. So, the workflow would be something like this:
- ngSwitch watches expression and transcludes appropriate ngSwitchWhen element.
- bnPreload stops the processing.
- bnPreload lazy-loads module.
- bnPreload transcludes the ngInclude element.
- ngInclude makes HTTP request to populate template cache.
- ngInclude compiles and appends content.
I didn't want to mess with the actual ngInclude code - I wanted the bnPreload directive to use the normal directive compilation and linking rules. Now, when it comes to compiling and transcluding in AngularJS, I'm still a total novice; however, after about an hour of tinkering I finally figured something out.
In the following demo, I have two ngSwitchWhen cases that both lazy-load data before allowing the relevant ngInclude directives to proceed. In order to simulate the lazy-loading, I'm using $timeout() and promises. The trick was to allow both the ngSwitchWhen and the bnPreload directives to compile and transclude the same element. The complication laid in the fact that bnPreload had to clean up after itself.
<!doctype html>
<html ng-app="Demo" ng-controller="DemoController">
<head>
<meta charset="utf-8" />
<title>
Preloading Data Before Executing ngInclude In AngularJS
</title>
<style type="text/css">
div[ ng-switch ],
div[ ng-switch ] * {
border: 1px solid red ;
margin: 0px 0px 0px 0px ;
padding: 10px 10px 10px 10px ;
}
</style>
</head>
<body>
<h1>
Preloading Data Before Executing ngInclude In AngularJS
</h1>
<p>
Subview: {{ subview }}
</p>
<!--
Each of the Cases in the following switch statement has both
an ngInclude and a bnPreload directive. The bnPreload will
defer the ngInclude execution until the given data has been
preloaded and made available.
-->
<div ng-switch="subview">
<div
ng-switch-when="one"
bn-preload="oneData"
bn-include-log
ng-include=" 'one.htm' ">
</div>
<div
ng-switch-when="two"
bn-preload="twoData"
bn-include-log
ng-include=" 'two.htm' ">
</div>
</div>
<p>
<a href="#" ng-click="toggleSubview()">Toggle Subview</a>
</p>
<!-- 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.0.7.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the main demo.
app.controller(
"DemoController",
function( $scope ) {
// I determine which subview to render.
$scope.subview = "one";
// I hold the data to render in the subview content
// (not show in the local HTML).
$scope.data = null;
// ---
// PUBLIC METHODS.
// ---
// I toggle between the subview settings.
$scope.toggleSubview = function() {
if ( $scope.subview === "one" ) {
$scope.subview = "two";
} else {
$scope.subview = "one";
}
};
// I set the data (from a child scope).
$scope.setData = function( newData ) {
$scope.data = newData;
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I load data with a simulated remote latency. This is the
// data that will be "preloaded" before the ngInclude is
// allowed to execute its linking function.
app.service(
"preloader",
function( $q, $timeout ) {
this.load = function( target ) {
if ( target === "oneData" ) {
var data = "[ First Item Data ]";
} else {
var data = "[ Second Item Data ]";
}
var deferred = $q.defer();
// Simulate network latency with deferred resolution.
$timeout(
function() {
deferred.resolve( data );
},
( 2 * 1000 )
);
return( deferred.promise );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I repload the given data before I let the rest of the
// directives
app.directive(
"bnPreload",
function( preloader ) {
// Compile the element so that we have access to the
// transclude function which will allow us to gain
// access to the target DOM element (in the linking
// phase) after it has been ripped out of the DOM by
// the compilation process.
function compile( templateElement, templateAttribute, transclude ) {
function link( $scope, element, attributes ) {
// When we are preloading the data, we'll put
// a loading indicator in the DOM. I probably
// wouldn't do this in production (in this
// fashion), but for the demo, it will be nice
// to see the feedback.
var loadingElement = $( "<div>Preloading...</div>" )
.css({
color: "#CCCCCC",
fontStyle: "italic"
})
;
// Once the element is transcluded, we'll have
// to keep track of it so we can remove it
// later (when destroyed).
// --
// NOTE: This is NOT the same element that the
// ngSwitch will have reference to.
var injectedElement = null;
// Show the "loading..." element.
element.after( loadingElement );
// Keep track of whether or not the $scope has
// been destroyed while the data was loading.
var isDestroyed = false;
// Preload the "remote" data.
preloader.load( attributes.bnPreload ).then(
function( preloadedData ) {
// if the scope / UI has been destoyed,
// the ignore the processing.
if ( isDestroyed ) {
return;
}
$scope.setData( preloadedData );
// Once the given data has been
// preloaded, we can transclude and
// inject our DOM node. Note that this
// DOM node has the ngInclude directive
// on it which will now execute.
transclude( $scope, function( copy ) {
loadingElement.remove();
element.after( injectedElement = copy );
});
}
);
// When the scope is destroyed, we have to be
// very careful to clean up after ourselves.
// Since the injected element we have a handle
// on is DIFFERENT than the element that the
// ngSwitch has a handle on, the ngSwitch-based
// destroy will leave our injected element in
// the DOM.
$scope.$on(
"$destroy",
function() {
isDestroyed = true;
loadingElement.remove();
// Wrap in $() in case it's still null.
$( injectedElement ).remove();
}
);
}
return( link );
}
// Return directive configuration.
// NOTE: ngSwitchWhen priority is 500.
// NOTE: ngInclude priority is 0.
return({
compile: compile,
priority: 250,
terminal: true,
transclude: "element"
});
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// This directive has the same default priority as ngInclude;
// as such, it will help us see when the ngInclude directive
// is actually
app.directive(
"bnIncludeLog",
function() {
// I bind the $scope to the user interface.
function link( $scope, element, attribute ) {
console.log( "Included:", attribute.bnPreload );
}
// Return directive configuration.
return({
link: link
});
}
);
</script>
</body>
</html>
At this point, I honestly don't want to say too much more since I am sure that I will do more to mislead than to inform. Simple linking in AngularJS, I understand. It makes perfect sense to me. Transcluding and linking, on the other hand, is still very new and confusing. If I try to explain how it works, I'll probably just end up confusing myself.
Want to use code from this post? Check out the license.
Reader Comments
Ben, what does your "bn-include-log" directive do?
Not really sure I understand what the benefit of lazy loading is. What is the end game with require js?
@Brad,
the bnIncludeLog was simply to see *when* directives at a lower priority (lower that bnPreload) would be linked. Since bnIncludeLog and ngInclude both have a priority of zero, I was assuming that it would, therefor, indicate when the ngInclude was executed.
Basically, I wanted to make sure that ngInclude *execution* was actually being delayed not just the rendering (ie, it was being linked, but not shown until it was transcluded).
Sorry, that's confusing, I know -- I'm still wrapping my head around it.
The end-game here is to be able to defer the loading of portions of an AngularJS application until they are actually being used. Right now, I load ALLLL my app at the start (JS + Views). But, the app is getting fairly large. What I'd like to do is defer loading of not-commonly-used portions until they are actually used by the user.
Right now, I'm just exploring and trying to understand how all this stuff works.
@Ben, that makes sense. We have just started a full blown angular app and I expect that I will be traveling down this road myself soon enough.
@Brad,
For my last few blog posts, I've been building off the concept laid out here:
http://ify.io/lazy-loading-in-angularjs/
But, Ify rocks his preload in the Routing mechanism. I have a somewhat different approach to my view rendering; so, I wanted to see if I could move the preloading closer to the HTML that requires it.
Good luck with your App - I've been *loving* AngularJS. And, apparently v1.2 just came out today!
Not sure/understanding why you want to go this direction. The HMTL is now controling which data is loaded. I still try to see HTML as presentation without (to much) logic, what to load. That's all inside js. Preloading sounds to me like against all the whole async idea?
If you put things inside lazy controllers, the controller is always fired first, the 2way binding scope magic will do its job if needed.
So the magic is the html controller call thatnmakes any controller prebind/load the JS.
As always thank for your greatcontributions, insides and doubts.
@Dutch Programmer,
You raise a good question - if the only thing I were loading was "data," then yes, I would simply load the controller and then load the data asynchronously inside the controller (if that is what you're saying). In fact, most of my controllers actually do work in this fashion:
1. Initialize.
2. Show "loading" UI.
3. Load "remote data" asynchronously.
4. Render data (once loaded).
However, the root desire here isn't just to load data - that's just the proof-of-concept; the real desire here is load actual *Modules* "on the fly" when pulling up parts of a UI.
In my previous post:
www.bennadel.com/blog/2554-Loading-AngularJS-Components-With-RequireJS-After-Application-Bootstrap.htm
... I talk about lazy-loading modules. However, in that post, I needed the calling controller to know all about the lazy-loading.
What I'd really like is to decouple the lazy-loading from the controller and bring it closer to the part of the app that actually needs it. And, since most of my UI is built with ngSwitch/ngInclude statements, I think I could insert the login into the ngInclude "preload"... which is where this current post comes into play.
So, instead of simply preloading "data", imagine that the "preload" is for the UI/Controllers that are about to be consumed.
You could simply use a variable for the src attribute that will be published after the data is loaded and a ng-hide to not show the container while loading the html, which will be switched by the onload attribute or the event that is broadcasted by ng-include itself.
So you would save a lot of code for this effect.
@Michael,
I've been thinking a lot about your suggestion. I think it does make sense. However, I am not sure how much code it would end up saving. To get the same kind of functionality, I would still need:
1. To $watch the switch expression for changes so that I could preload the right data (based on the switch evaluation).
2. Have a directive on the switch case so that I could update the DOM with the "Preloading..." message while the data loads.
3. To have the preload mechanism (ie, the thing that actually does the data loading).
That said, I think I could probably put the "Preloading..." message inside the Div that will get swapped out with the ngInclude since ngInclude should replace the content of the Div. So, I think I could get away with needing a directive to show the message. But, I would still need something to do handle the loading, which means that the logic either needs to go into the parent Controller; or, I would need to create a Directive/Controller on the switch case.
so... how do i use bn-preload, with angular1.2?
i love the idea of prereqs before an ng-include fires... but the compound transclusion thing is killing me!
find a workaround?
you mentioned the thing in your compound transclusion article, but the only suggestion was to wrap a <div ng-include> inside the ng-switch... but didnt mention how the bn-preload could work.
thanks!