This problem has been taking up the last day or so.
I've been trying to get my AngularJS application to load the script files for each state's components lazily. I'm working on a large project at work in Angular, and the index.html
file has morphed into over 100 <script>
tags including the JS for the various controllers, services, and libraries. Most of them are small, so it isn't so much that loading time is a HUGE problem (though it could be), but it just never looked clean to me.
Maybe it's because I've gotten used to PHP's autoloader or have just been spoiled by all of the languages that can load their own dependencies at compile time. It's just not modular to have to load scripts for some minor, fringe state's directive in the root document of the application, or for the module that directive actually belongs to not to load the script itself if it's moved into another application without the <script>
list of glory.
Either way, I'm starting a new project and want to keep it cleaner, but loading components into Angular in this way presents a number of challenges. A lot of them have been addressed at one time or another in the documentation or some blog post, SO question, or another, but I've yet to see an end-to-end solution that integrates cleanly with other Angular components.
- Angular only bootstraps the
ng-app
directive if Angular and the modules are already loaded when the page is rendered. Even starting the application with lazy-loading requires a workaround.
- The module API's methods only work before an application is bootstrapped. Registering new controllers, directives, filters, or services after the application has been bootstrapped, but after the scripts defining them have actually been loaded (and when they're actually needed) requires a workaround.
- Both lazy loading scripts and invoking AJAX-based services require the invocation of callbacks, and injecting the result of service calls into state controllers requires those services to actually exist to be called when the state transition starts. Actually INVOKING a lazily loaded service and resolving it before the state changes...requires a workaround.
- All of this needs to fit together in a way that doesn't look kludgy and can easily be reused in multiple applications without reinventing the wheel each time.
I've seen answers to #1 and #2. Obviously, angular.bootstrap
can be used to start up a module after the whole page has loaded without an ng-app
directive. Adding components after bootstrapping is a little less obvious, but saving references to the various $provider
services in the config blocks does the trick, overwriting the module
API more seamlessly so. Resolving #3 and doing it all in a way that satisfies #4 has been a bit more elusive.
The above examples solving #2 were for controllers and directives. Adding in services turns out to be a little bit more complicated, asynchronous ones, lazily loaded, and meant to provide their data to a lazily loaded controller especially so. With respect to Mr. Isitor, his code certainly works for registering a controller as a proof of concept, but the code is not written in a way that easily scales up to the kind of application for which lazy-loading the scripts makes sense, a much larger application with tens to hundreds of includes, dependencies, and asynchronous services.
I'm going to post the solution I came up with, but if anyone has suggestions to improve it or has already found a dramatically and radically different, better way, please feel free to add it on.
Here's the code for an Angular module lazy
, depending on the ui.router
module. When it's included in your module's dependencies, the lazy loading functionality of the state's scripts will be enabled. I've included examples of the primary app module, a few lazy components, and my index.html
, sanitized for demonstration purposes. I'm using the Script.js
library to actually handle the script loading.
angular-ui-router-lazy.js
/**
* Defines an AngularJS module 'lazy' which depends on and extends the ui-router
* module to lazy-load scripts specified in the 'scripts' attribute of a state
* definition object. This is accomplished by registering a $stateChangeStart
* event listener with the $rootScope, interrupting the associated state change
* to invoke the included $scriptService which returns a promise that restarts the
* previous state transition upon resolution. The promise resolves when the
* extended Script.js script loader finishes loading and inserting a new <script>
* tag into the DOM.
*
* Modules using 'lazy' to lazy-load controllers and services should call lazy.makeLazy
* on themselves to update the module API to inject references for the various $providers
* as the original methods are only useful before bootstrapping, during configuration,
* when references to the $providers are in scope. lazy.makeLazy will overwrite the
* module.config functions to save these references so they are available at runtime,
* after module bootstrapping.
* See http://ify.io/lazy-loading-in-angularjs/ for additional details on this concept
*
* Calls to $stateProvider.state should include a 'scripts' property in the object
* parameter containing an object with properties 'controllers', 'directives', 'services',
* 'factories', and 'js', each containing an array of URLs to JS files defining these
* component types, with other miscelleneous scripts described in the 'js' array.
* These scripts will all be loaded in parallel and executed in an undefined order
* when a state transition to the specified state is started. All scripts will have
* been loaded and executed before the 'resolve' property's promises are deferred,
* meaning services described in 'scripts' can be injected into functions in 'resolve'.
*/
(function() {
// Instantiate the module, include the ui.router module for state functionality
var lazy = angular.module('lazy',['ui.router']);
/**
* Hacking Angular to save references to $providers during module configuration.
*
* The $providers are necessary to register components, but they use a private injector
* only available during bootstrap when running config blocks. The methods attached to the
* Vanilla AngularJS modules rely on the same config queue, they don't actually run after the
* module is bootstrapped or save any references to the providers in this injector.
* In makeLazy, these methods are overwritten with methods referencing the dependencies
* injected at configuration through their run context. This allows them to access the
* $providers and run the appropriate methods on demand even after the module has been
* bootstrapped and the $providers injector and its references are no longer available.
*
* @param module An AngularJS module resulting from an angular.module call.
* @returns module The same module with the provider convenience methods updated
* to include the DI $provider references in their run context and to execute the $provider
* call immediately rather than adding calls to a queue that will never again be invoked.
*/
lazy.makeLazy = function(module) {
// The providers can be injected into 'config' function blocks, so define a new one
module.config(function($compileProvider,$filterProvider,$controllerProvider,$provide) {
/**
* Factory method for generating functions to call the appropriate $provider's
* registration function, registering a provider under a given name.
*
* @param registrationMethod $provider registration method to call
* @returns function A function(name,constructor) calling
* registationMethod(name,constructor) with those parameters and returning the module.
*/
var register = function(registrationMethod) {
/**
* Function calls registrationMethod against its parameters and returns the module.
* Analogous to the original module.config methods but with the DI references already saved.
*
* @param name Name of the provider to register
* @param constructor Constructor for the provider
* @returns module The AngularJS module owning the providers
*/
return function(name,constructor) {
// Register the provider
registrationMethod(name,constructor);
// Return the module
return module;
};
};
// Overwrite the old methods with DI referencing methods from the factory
// @TODO: Should probably derive a LazyModule from a module prototype and return
// that for the sake of not overwriting native AngularJS code, but the old methods
// don't work after `bootstrap` so they're not necessary anymore anyway.
module.directive = register($compileProvider.directive);
module.filter = register($filterProvider.register);
module.controller = register($controllerProvider.register);
module.provider = register($provide.provider);
module.service = register($provide.service);
module.factory = register($provide.factory);
module.value = register($provide.value);
module.constant = register($provide.constant);
});
// Return the module
return module;
};
/**
* Define the lazy module's star $scriptService with methods for invoking
* the extended Script.js script loader to load scripts by URL and return
* promises to do so. Promises require the $q service to be injected, and
* promise resolutions will take place in the Script.js rather than Angular
* scope, so $rootScope must be injected to $apply the promise resolution
* to Angular's $digest cycles.
*/
lazy.service('$scriptService',function($q,$rootScope) {
/**
* Loads a batch of scripts and returns a promise which will be resolved
* when Script.js has finished loading them.
*
* @param url A string URL to a single script or an array of string URLs
* @returns promise A promise which will be resolved by Script.js
*/
this.load = function(url) {
// Instantiate the promise
var deferred = $q.defer();
// Resolve and bail immediately if url === null
if (url === null) { deferred.resolve(); return deferred.promise; }
// Load the scripts
$script(url,function() {
// Resolve the promise on callback
$rootScope.$apply(function() { deferred.resolve(); });
});
// Promise that the URLs will be loaded
return deferred.promise;
};
/**
* Convenience method for loading the scripts specified by a 'lazy'
* ui-router state's 'scripts' property object. Promises that all
* scripts will be loaded.
*
* @param scripts Object containing properties 'controllers', 'directives',
* 'services', 'factories', and 'js', each containing an array of URLs to JS
* files defining those components, with miscelleneous scripts in the 'js' array.
* any of these properties can be left off of the object safely, but scripts
* specified in any other object property will not be loaded.
* @returns promise A promise that all scripts will be loaded
*/
this.loadState = function(scripts) {
// If no scripts are given, instantiate, resolve, and return an easy promise
if (scripts === null) { var d = $q.defer; d.resolve(); return d; }
// Promise that all these promises will resolve
return $q.all([
this.load(scripts['directives'] || null),
this.load(scripts['controllers'] || null),
this.load(scripts['services'] || null),
this.load(scripts['factories'] || null),
this.load(scripts['js'] || null)
]);
};
});
// Declare a run block for the module accessing $rootScope, $scriptService, and $state
lazy.run(function($rootScope,$scriptService,$state) {
// Register a $stateChangeStart event listener on $rootScope, get a script loader
// for the $rootScope, $scriptService, and $state service.
$rootScope.$on('$stateChangeStart',scriptLoaderFactory($scriptService,$state));
});
/**
* Returns a two-state function for handing $stateChangeStart events.
* In the first state, the handler will interrupt the event, preventing
* the state transition, and invoke $scriptService.loadState on the object
* stored in the state definition's 'script' property. Upon the resolution
* of the loadState call, the handler restarts a $stateChangeStart event
* by invoking the same transition. When the handler is called to handle
* this second event for the original state transition, the handler is in its
* second state which allows the event to continue and the state transition
* to happen using the ui-router module's default functionality.
*
* @param $scriptService Injected $scriptService instance for lazy-loading.
* @param $state Injected $state service instance for state transitions.
*/
var scriptLoaderFactory = function($scriptService,$state) {
// Initialize handler state
var pending = false;
// Return the defined handler
return function(event,toState,toParams,fromState,fromParams) {
// Check handler state, and change state
if (pending = !pending) { // If pending === false state
// Interrupt state transition
event.preventDefault();
// Invoke $scriptService to load state's scripts
$scriptService.loadState(toState.scripts)
// When scripts are loaded, restart the same state transition
.then(function() { $state.go(toState,toParams); });
} else { // If pending === true state
// NOOP, 'ui-router' default event handlers take over
}
};
};
})();
/** End 'lazy' module */
index.html
<!DOCTYPE html>
<html>
<head>
<title>Lazy App</title>
<script type='text/javascript' src='libs/script.js'></script>
<script type='text/javascript'>
$script.queue(null,'libs/angular/angular.min.js','angular')
.queue('angular','libs/angular/angular-ui-router.min.js','ui-router')
.queue('ui-router','libs/angular/angular-ui-router-lazy.js','lazy')
.queue('lazy',null,'libs-angular')
.queue('libs-angular','lazyapp/lazyapp.module.js','lazyapp-module');
$script.ready('lazyapp-module',function() { console.log('All Scripts Loaded.'); });
</script>
</head>
<body>
<div ui-view='mainView'></div>
</body>
</html>
Function Hacked into Script.js because I Prefer the Syntax
$script.queue = function(aQueueBehind,aUrl,aLabel) {
if (aQueueBehind === null) { return $script((aUrl === null?[null]:aUrl),aLabel); }
$script.ready(aQueueBehind,function() {
if (aUrl !== null)
$script(aUrl,aLabel);
else
$script.done(aLabel);
});
return $script;
}
lazyapp.module.js
(function() {
var lazyApp = angular && angular.module('lazyApp ',['lazy']);
lazyApp = angular.module('lazy').makeLazy(lazyApp);
lazyApp.config(function($stateProvider) {
$stateProvider.state({
name: 'root',
url: '',
views: {
'mainView': { templateUrl: '/lazyapp/views/mainview.html', controller: 'lazyAppController' }
},
scripts: {
'directives': [ 'lazyapp/directives/lazyheader/src/lazyheader.js' ],
'controllers': [ 'lazyapp/controllers/lazyappcontroller.js' ],
'services': [ 'lazyapp/services/sectionservice.js' ]
},
resolve: {
sections: function(sectionService) {
return sectionService.getSections();
}
}
});
});
angular.bootstrap(document,['lazyApp']);
})();
sectionservice.js
(function() {
var lazyApp = angular.module('lazyApp');
lazyApp.service('sectionService',function($q) {
this.getSections = function() {
var deferred = $q.defer();
deferred.resolve({
'home': {},
'news': {},
'events': {},
'involved': {},
'contacts': {},
'links': {}
});
return deferred.promise;
};
});
})();
lazyheader.js
(function() {
var lazyApp = angular.module('lazyApp ');
lazyApp.directive('lazyHeader',function() {
return {
templateUrl: 'lazyapp/directives/lazyheader/templates/lazyheader-main.html',
restrict: 'E'
};
});
})();
lazyappcontroller.js
(function() {
var lazyApp = angular.module('lazyApp ');
lazyApp.controller('lazyAppController',function(sections) {
// @TODO: Control things.
console.log(sections);
});
})();