Lazy-Loading Widgets in AngularJS

by Thomas Urban

AngularJS is a great framework for building rich web applications. However as of today it is still lacking some integrated support for lazy-loading components e.g. due to user interacting with an application in different ways thus not requiring to load to whole logics as part of bootstrapping web application. This article tries to provide another approach for properly lazy-loading widgets.

Why Lazy-Loading?

Lazy-Loading components improves an application's load time. There are no reasons for a web application to actually feel like an application in that it takes a few seconds to load all components ever required to interact with user. By lazy-loading components an application may provide views to user more instantly. If a user decides to do more with presented data application starts loading required components on demand.

Properly lazy-loading components also improves a user's experience in working with an application instead of a rich web site. Taken the AngularJS Blog for example clicking on links is always refetching core logics of this site resulting in completely different web pages to be fetched and bootstrapped again rather than loading just that new view's logics and content while keeping shared parts of view.

Existing Approaches

On trying to explore opportunities of lazy-loading stuff in AngularJS I stumbled over several posts. Ben Nadel was trying to extend directive ngInclude to lazy-load some data prior to showing included template. Ifeanyi Isitor is providing an interesting approach for lazy-loading logics into AngularJS. AngularJS is known to most probably include support for lazy-loading in an upcoming 2.0 release, though schedule on this is still unclear. Nevertheless blog was recently referring to some ongoing discussion on how to support lazy-loading then.

Neither of the approaches fit into what I was looking for: Ben Nadel's approach was focusing on loading some related data, only, while the other two strongly focus on using ngRoute for integrating components and I wasn't yet intended to use. In addition either of those last two approaches require some knowledge in core about what lazy-loaded components rely on. If an application wants to load some component in future it has to know dependencies of component to load when bootstrapping. In case of the last approach the application has to prepare placeholders of every module it might be going to load in near future. Either case requires some tooling to generate maps of used widgets and their individual dependencies to process.

What I Was Looking For

In opposition to those solutions I was more interested in actually having some kind of sub-application that is embedded into another one for serving a particular purpose. Of course, the embedding application should have as little knowledge about the embedded component as possible. The embedded component should be running without loading embedding application first. This would improve the components testability. My desired solution wasn't that much depending on deep-linking using route declaration. Instead I focused on having some code requesting to load some rich template resulting in a component to be embedded. Any interaction between the embedding application and the embedded component might rely on AngularJS events to be emitted up and down the hierarchy of scopes. This way there wouldn't be any strong bindings between application and component for components might ignore events send by application and vice versa.

My Solution

Resolve Dependencies On Lazy-Loading

So I sticked with the approach of Ben Nadel by trying to extend directive ngInclude.

ngInclude is basically split in two parts. The first part is triggering $templateRequest to load some template while the second is used to process loaded template as part of transcluding that first part. The transclusion is deferred until having loaded selected template.

My intention was to insert some code furtherly deferring transclusion of loaded template until also having loaded all assets referenced from that template. This would imply to split directive again resulting in three parts with the first two parts trying to transclude whenever some internally triggered phase of processing has been finished. However, transclusion doesn't work this way.

That's why code of ngInclude was copied for extending its first part to contain that second phase described before. This copy basically differs in two aspects from original as contained in AngularJS:

  1. It is declaring directive cWidget instead of ngInclude.
  2. It is inserting another handler on promise of $templateRequest used to load dependencies of template prior to transcluding it.

Extend AngularJS After Having Bootstrapped Application

Code is now able to defer inclusion of template until having loaded scripts and stylesheets selected in template code. But those lazily loaded scripts can't register new directives and controllers. This is due to the fact that methods of angular.module() are actually just enqueueing invocations of underlying services for running queued invocations as part of bootstrapping later. After bootstrapping further calls to same methods enqueue more invocations that never get actually called for queue isn't processed ever again.

My approach is thus adopting part of Ifeanyi Isitor's solution by extracting references on underlying methods. It is then extending this extraction by using those references to replace existing methods of a bootstrapped AngularJS application to properly forward invocations after bootstrap. This is achieved by adding another job to be run on bootstrapping.

Combining Separate Modules Into One

One intention was to enable components work on their own e.g. for testing purposes as this is one of the drawbacks of Ifeanyi Isitor's solution. This is achieved by replacing methods of application's module as described before. In addition every component might use its own module when running on its own while it has to work with same module as application it is embedded into on lazy-loading.

Our solution is thus extracting singleton reference on application's module to be globally available as window.app. This enables to use it in preference over some freshly declared module to use when running component on its own.

( window.app = angular.module( "name-of-application", [] ) )
.config( [..., function( ... ) {
}] )

When inserting solution right after loading AngularJS all further scripts might start accessing angular like this:

( window.app || angular.module( "some-module-name", [] ) )
.controller( "SomeController", [ "$scope", function( $scope ) {
} ] );

Of course, your application's HTML needs to address AngularJS to be loaded. But every component must do so as well for enabling it to run on its own.

When components are requesting to load AngularJS on embedding them into application the latter is trying to load AngularJS again which is resulting in undesired warnings logged on console. Because of that marks on addressed dependencies are supported to skip those dependencies on lazy-loading component. This mark is set by adding attribute data-standalone to every script or stylesheet not to be loaded when embedding as component.

Putting All Together

Application

Consider to have HTML file index.html declaring root of your application like this:

<!DOCTYPE html>
<html ng-app="name-of-application">
<head>
<script src="/angular/angular.js" data-standalone></script>
<script src="/logics/widget.js" data-standalone></script>
<script src="/logics/application.js"></script>
</head>
<body ng-controller="ApplicationController">
<div c-widget="component"></div>
</body>
</html>

It is loading AngularJS, our solution to lazy-loading and some application-specific code controlling selection of component to embed in file /logics/application.js:

( window.app || angular.module( "application", [] ) )
.controller( "ApplicationController", ["$scope", function( $scope ) {
$scope.component = "/widget/component.html";
}] );

Component

The component's HTML might look like this:

<!DOCTYPE html>
<html ng-app="component">
<head>
<script src="/angular/angular.js" data-standalone></script>
<script src="/logics/component.js"></script>
</head>
<body ng-controller="ComponentController">
<input type="text" ng-model="text">
<button ng-disabled="!maySend()">Send</button>
</body>
</html>

As you can see the component is also loading AngularJS (and might be loading code of our solution as well) unless being embedded into some existing application. The special code of this component is found in /logics/component.js reading like this:

( window.app || angular.module( "component", [] ) )
.controller( "LoginController", [ "$scope", function( $scope ) {
$scope.text = "";
$scope.maySend = function() {
return $scope.text && $scope.text.trim();
};
} ] );

Now the component in second HTML document might be run on its own and as part of application in first HTML document.

Known Weaknesses

This approach is actually bad for copying some code just to behave at least equivalent to some existing directive. I'd prefer inserting the second stage by declaring another part of directive ngInclude with priority between the two existing ones. But this does not work for eligible reasons. Maybe it would help to have ngInclude adding another hook to inject that second stage, but this isn't available in AngularJS currently. Of course, every future improvement to code of ngInclude must be adopted by this solution for keep it semantically equivalent to ngInclude.

Furthermore the lack of properly integrating with routes is something to criticize. However, routes may still be used to trigger the code of embedding application that is selecting component to embed. By using directive equivalent to ngInclude with support for nesting scopes it is possible to send events for interacting with component no matter loaded component is actually supporting them or not.

By extracting some singleton stored in global variable scope this approach most probably doesn't work with multiple ng-app occurrences per single HTML document. However, this kind of mash-up requires to load all those combined application's code on bootstrapping making it contradictive to intentions of our solution.

I haven't tested whether or not lazy-loaded components may declare anything else but controllers. As far as I know it will fail to properly declare providers then. However, for some basic intentions of separating commonly useful UI components this currently doesn't matter to me.

Finally there might be a lot more issues to state here for I'm most probably not as sophisticated in using AngularJS as required to eventually capture every aspects of how to extend it or not.

Download Solution

Download the full content of /logics/widget.js as mentioned in code examples above.

Keep in mind to adjust name of module it is declaring to match the one that is selected in your application's HTML document.

Go back