AngularJS: RequireJS, dynamic loading and pluggable views

I’ve been using AngularJS for some time now and find declarative templates, the testability and dependency injection great.What I especially like is that the fact that AngularJS prescribes and dictates quite a lot, you are forced to bring some structure to the code of your application. This tends to reduce the velocity of the degeneration you often observe in larger JavaScript projects, where chaos slowly takes over.

But one of the things which was bothering me was that I always ended up adding some libraries/modules and forgetting to add them to my index.html. And sometimes after the list of loaded scripts grew quite a lot, figuring out where to insert this new script tag is really a pain.

Introducing RequireJS

So I decided some time ago to start using RequireJS to bring order in what was slowly becoming a mess. RequireJS does exactly what’s missing when you only rely on the AngularJS dependency injection: a file loader which supports dependencies and a way to define dependencies non-angular scripts (plain old JavaScript files).

Defining your AngularJS modules as RequireJS AMD modules with dependencies just means wrapping them in  a call to the define function e.g.:

define([
    'angular',
    'moment'
], function (angular, moment) {
    return angular.module('admin', [])
    	.controller('AdminController', ...);
});

Instead of loading all the scripts, you can replace all script tags by one of them loading RequireJS and referencing your main JavaScript file where RequireJS will be configured:

<script src="vendor/requirejs/require.js" data-main="scripts/main"></script>

main.js will contain the RequireJS configuration e.g.:

require.config({
    paths: {
        jquery: "../vendor/jquery/dist/jquery.min",
        jqueryui: "../vendor/jquery-ui/jquery-ui.min",
        angular: "../vendor/angular/angular.min",
        ngRoute: "../vendor/angular-route/angular-route.min",
    },
    shim: {
        angular: {exports: 'angular', deps: ['jquery']},
        jqueryui: {deps: ['jquery']},
        jquery: {exports: '$'},
        ngRoute: ["angular"],
    }
});

You also need to manually bootstrap your application to make sure that RequireJS loads all required dependency first. This is done by removing the ng-app attribute from your index.html and instead calling angular.bootstrap in main.js e.g.:

require.config({
	...
});

require([
        'angular',
        'scripts/app'
    ], function (angular, app) {
        angular.element(document).ready(function () {
            angular.bootstrap(document, ['cards']);
        });
    }
);

This will load AngularJS, then load your app and then bootstrap your application.

Introducing RequireJS is actually quite some work (if all your files and modules are already there) but not really complex. The main issue I have faced was that there is no explicit dependencies between AngularJS and JQuery, so they might be loaded in any random order. If AngularJS is loaded before jQuery, it will revert to using jqLite which is definitely not sufficient for jQuery UI. This will lead to strange errors occuring. So what you need to do is make sure that jQuery is loaded before AngularJS. This can either be done by setting priorities in your RequireJS configuration (only works with RequireJS 1.x) or by making jQuery a dependency of AngularJS (in the shim section of RequireJS 2.x – see example above).

OK, after all small problem were solved and my application was running again, I looked back at what I had done and realized that even though I didn’t ever had to add a script tag to my main HTML file, it wasn’t yet as great as I thought it would be before I started moving to RequireJS. First, I still have to manage dependencies on 3 levels:

  1. Dependencies between files i.e. file load order
  2. Dependencies between AngularJS modules
  3. Dependencies to individual controllers or providers

The first kind of dependencies is now managed by RequireJS instead of having me manage it in index.html. The other two have not changed.

Moreover, my application still looked like a big monolithic application at runtime since even though the file load order was now computed by RequireJS based on dependencies, I was still loading all files and interpreting them at application start. No matter whether some modules, controllers, directives… might only be needed much later or for specific users not at all.

Introducing Lazy Loading

So I started looking into lazy loading. Luckily I quickly realized that the step introducing RequireJS in my application hadn’t been useless. It is indeed possible to lazy load controllers, directives and such in AngularJS without resorting to using RequireJS. But you then need to manage the JavaScript files containing them and their dependencies to external libraries manually. With RequireJS, all you need to do to have all required files loaded before registering the lazy loaded controllers, views and such is to wrap it all in a require call.

There are different levels of lazy loading which can be achieved in AngularJS (having different levels of difficulty and restrictions).

Dynamically Loading Controllers and Views

The first level of lazy loading is dynamically loading controllers and views to an already loaded AngularJS module. To associate views with controllers, you would typically put your routing code in a module’s config function:

$routeProvider
     .when('/customers',
        {
            controller: 'CustomersController',
            templateUrl: 'views/customers.html'
        })
    .when('/orders',
        {
            controller: 'OrdersController',
            templateUrl: 'views/orders.html'
        })
    .otherwise({ redirectTo: '/customers' });

This means that everything will be loaded at once at runtime using the $routeProvider object. The referenced controller must have already been registered or you will get an error while defining the routing. Lazy loading the views (i.e. the referenced template URL) is supported out of the box but the JavaScript code for your controllers needs to be loading upfront.

To allow lazy loading, you will need to use the resolve property (additionally to the templateUrl) and also make sure that your controllers are properly registered once the JavaScript file is loaded. So using RequireJS to load the scripts and their dependencies, your code would look like this:

$routeProvider.when('/customers', {
    controller: 'CustomersController',
    templateUrl: 'views/customers.html',
    resolve: {
        resolver: ['$q','$rootScope', function($q, $rootScope)
        {
            var deferred = $q.defer();
            require(['views/customers'], function()
            {
                $rootScope.$apply(function()
                {
                    deferred.resolve();
                });
            });
            return deferred.promise;
        }]
    }
});

This makes sure that your controller doesn’t need to present immediately but that the resolver function will be called when this route is activated. The resolver function returns a promise we create using $q.defer. In order to load all required files and AMD modules, we wrap the logic in this function in a require block. Once the loading of the script and all dependencies is done, our callback is called which just resolves the deferred object.

Now, there is still a problem. All files will be loaded but you won’t be able to use the new controllers because they were created after startup. In order to have them properly registered, you will need to overwrite a few functions of your angular module to use compiler providers (such as $controllerProvider) instead (this needs to be done before using the $routeProvider). So you’re application js file would look like this:

define([
    'angular'
], function (angular) {
    var app = angular.module('app', []);

    app.config(['$routeProvider',
        '$controllerProvider',
        '$compileProvider',
        '$filterProvider',
        '$provide',
        function ($routeProvider, $controllerProvider, $compileProvider, $filterProvider, $provide) {
            app.controller = $controllerProvider.register;
            app.directive = $compileProvider.directive;
            app.filter = $filterProvider.register;
            app.factory = $provide.factory;
            app.service = $provide.service;

            $routeProvider.when(...);

            $routeProvider.otherwise(...);
        }]);

    return app;
});

Now you will see that when you activate this view, not only the HTML template file will be loaded on the fly, but also the JavaScript file containing your controller. And your controller will be accessible from the view.

This approach works well and doesn’t require much additional code but it only works if the controller you’re lazy loading is controller on an existing module. And even if it is the case, you’ll notice it doesn’t work that well, when the controllers you are lazy loading bring their own dependencies (other modules with controllers and directives).

Registering AngularJS modules dynamically

So to have a more robust and versatile solution, we need to be able to register and activate AngularJS modules dynamically (and the modules on which they are dependent).

In order to do it, we first need to understand what happens when you load a new module at startup. A module contains the following data which are relevant when you want to dynamically activate it:

  • A list of dependencies: module.requires
  • An invoke queue: module.invokeQueue
  • A list of config blocks to be executed on module load: module.configBlocks
  • A list of run blocks to be executed after injector creation: module.runBlocks

When a module is registered, AngularJS checks whether all referenced modules are available.

Whenever you call a function on your module (e.g. config, run, controller, directive, service…), it just pushes the provided function to a queue. In case of config, the queue is module.configBlocks. For run, it goes to module.runBlocks. For the others, it’s module.invokeQueue.

On module load, AngularJS will concatenate the run blocks (but without executing them yet), run the invoke queue and then run the config blocks. Once the modules are loaded, all run blocks will be executed.

When loading modules dynamically, the phase when modules are usually loaded is over, so even though the JavaScript files are loaded by RequireJS, the modules will not be properly initialized. So we just need to perform manually what’s usually done automatically by AngularJS.

Module Dependencies

So the first step is to make sure that modules on which this module is dependent are activated first. So the function to load a module would start like this:

this.registerModule = function (moduleName) {
    var module = angular.module(moduleName);

    if (module.requires) {
        for (var i = 0; i < module.requires.length; i++) {
            this.registerModule(module.requires[i]);
        }
    }

    ...
};

The Invoke Queue

Each entry in the invoke queue is an array with three entries:

  1. A provider
  2. A method
  3. Arguments

So in order to process it, we need to have a list of providers:

var providers = {
    $controllerProvider: $controllerProvider,
    $compileProvider: $compileProvider,
    $filterProvider: $filterProvider,
    $provide: $provide
};

And invoke the appropriate method with the provided arguments:

angular.forEach(module._invokeQueue, function(invokeArgs) {
    var provider = providers[invokeArgs[0]];
    provider[invokeArgs[1]].apply(provider, invokeArgs[2]);
});

The Config and Run Blocks

To execute the config and run blocks, you can just rely on $injector:

angular.forEach(module._configBlocks, function (fn) {
    $injector.invoke(fn);
});
angular.forEach(module._runBlocks, function (fn) {
    $injector.invoke(fn);
});

The registerModule function

So the complete registerModule function would look like this:

this.registerModule = function (moduleName) {
    var module = angular.module(moduleName);

    if (module.requires) {
        for (var i = 0; i < module.requires.length; i++) {
            this.registerModule(module.requires[i]);
        }
    }

    angular.forEach(module._invokeQueue, function(invokeArgs) {
        var provider = providers[invokeArgs[0]];
        provider[invokeArgs[1]].apply(provider, invokeArgs[2]);
    });
    angular.forEach(module._configBlocks, function (fn) {
        $injector.invoke(fn);
    });
    angular.forEach(module._runBlocks, function (fn) {
        $injector.invoke(fn);
    });
};

Registering pluggable views

Now that we can we can register modules dynamically, we’re only one step away from defining views which can be plugged into our application. Imagine you have an application, with multiple views showing different (possibly unrelated) data. If you want others to be able to extend your application, you need to provide a way to define a pluggable view and load all such views setting the appropriate routes and providing some model which can be used to display the corresponding navigation links.

In order to store all data required for configuring the routing and creating the navigation links, we’ll be using an object (viewConfig). The routing (assuming you’re using ngRoute) is then configured this way:

    $routeProvider.when(viewConfig.viewUrl, {
        templateUrl: viewConfig.templateUrl,
        controller: viewConfig.controller,
        resolve: {
            resolver: ['$q', '$timeout', function ($q, $timeout) {

                var deferred = $q.defer();
                if (angular.element("#"+viewConfig.cssId).length == 0) {
                    var link = document.createElement('link');
                    link.id = viewConfig.cssId;
                    link.rel = "stylesheet";
                    link.type = "text/css";
                    link.href = viewConfig.cssUrl;
                    angular.element('head').append(link);
                }
                if (viewConfig.requirejsConfig) {
                    require.config(viewConfig.requirejsConfig);
                }
                require([viewConfig.requirejsName], function () {
                    pluggableViews.registerModule(viewConfig.moduleName);
                    $timeout(function() {
                        deferred.resolve();
                    });
                });
                return deferred.promise;
            }]
        }
    });
};

It provides the following configuration possibilities:

viewConfig.viewUrl: it’s the relative routing URL e.g. “/admin”
viewConfig.templateUrl: it’s the relative URL for the HTML template of the view e.g. “views/admin/admin.html”
viewConfig.controller: it’s the name of the controller for the view e.g. “AdminController”
viewConfig.navigationText: it’s the text displayed on the navigation link e.g. “Administration”
viewConfig.requirejsName: it’s the name of the RequireJS AMD module e.g. “admin”
viewConfig.requirejsConfig: it’s the object to be added to the RequireJS configuration e.g. { paths: { ‘admin’: ‘views/admin/admin’ } }
viewConfig.moduleName: it’s the name of the module being loaded e.g. “app.admin”
viewConfig.cssId: it’s the ID of the link tag created to load the CSS stylesheet e.g. “admin-css”
viewConfig.cssUrl: it’s the relative URL of the CSS stylesheet file e.g. “views/admin/admin.css”

In order not to have to define all these parameters every time we call the function to register the view, we’ll first define some defaults to be set when some of these parameters are not explicitly set:

if (!viewConfig.viewUrl) {
    viewConfig.viewUrl = '/' + viewConfig.ID;
}
if (!viewConfig.templateUrl) {
    viewConfig.templateUrl = 'views/' + viewConfig.ID + '/' + viewConfig.ID + '.html';
}
if (!viewConfig.controller) {
    viewConfig.controller = this.toTitleCase(viewConfig.ID) + 'Controller';
}
if (!viewConfig.navigationText) {
    viewConfig.navigationText = this.toTitleCase(viewConfig.ID);
}
if (!viewConfig.requirejsName) {
    viewConfig.requirejsName = viewConfig.ID;
}
if (!viewConfig.moduleName) {
    viewConfig.moduleName = viewConfig.ID;
}
if (!viewConfig.cssId) {
    viewConfig.cssId = viewConfig.ID + "-css";
}
if (!viewConfig.cssUrl) {
    viewConfig.cssUrl = 'views/' + viewConfig.ID + '/' + viewConfig.ID + '.css';
}

Using this, it’s sufficient to call our provider function like this in order to have a link added for our administration view:

$pluggableViewsProvider.registerView({ ID: 'admin', moduleName: "admin", requirejsConfig: { paths: { 'admin': 'views/admin/admin' } } });

Since the way the navigation links are displayed pretty much depends on how you do it in your markup, our provider will not do it itself but just store the corresponding information and provide it through the provider:

this.views = [];
...
this.views.push(viewConfig);

So the complete module for our provider looks like this:

(function () {
    'use strict';

    define([
        'angular'
    ], function (angular) {
        return angular.module('pluggableViews', [])
            .provider('$pluggableViews', [
                '$controllerProvider',
                '$compileProvider',
                '$filterProvider',
                '$provide',
                '$injector',
                '$routeProvider',
                function ($controllerProvider, $compileProvider, $filterProvider, $provide, $injector, $routeProvider) {
                    var providers = {
                        $compileProvider: $compileProvider,
                        $controllerProvider: $controllerProvider,
                        $filterProvider: $filterProvider,
                        $provide: $provide
                    };
                    this.views = [];

                    var pluggableViews = this;

                    this.registerModule = function (moduleName) {
                        var module = angular.module(moduleName);

                        if (module.requires) {
                            for (var i = 0; i < module.requires.length; i++) {
                                this.registerModule(module.requires[i]);
                            }
                        }

                        angular.forEach(module._invokeQueue, function(invokeArgs) {
                            var provider = providers[invokeArgs[0]];
                            provider[invokeArgs[1]].apply(provider, invokeArgs[2]);
                        });
                        angular.forEach(module._configBlocks, function (fn) {
                            $injector.invoke(fn);
                        });
                        angular.forEach(module._runBlocks, function (fn) {
                            $injector.invoke(fn);
                        });
                    };

                    this.toTitleCase = function (str)
                    {
                        return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
                    };

                    this.registerView = function (viewConfig) {
                        if (!viewConfig.viewUrl) {
                            viewConfig.viewUrl = '/' + viewConfig.ID;
                        }
                        if (!viewConfig.templateUrl) {
                            viewConfig.templateUrl = 'views/' + viewConfig.ID + '/' + viewConfig.ID + '.html';
                        }
                        if (!viewConfig.controller) {
                            viewConfig.controller = this.toTitleCase(viewConfig.ID) + 'Controller';
                        }
                        if (!viewConfig.navigationText) {
                            viewConfig.navigationText = this.toTitleCase(viewConfig.ID);
                        }
                        if (!viewConfig.requirejsName) {
                            viewConfig.requirejsName = viewConfig.ID;
                        }
                        if (!viewConfig.moduleName) {
                            viewConfig.moduleName = viewConfig.ID;
                        }
                        if (!viewConfig.cssId) {
                            viewConfig.cssId = viewConfig.ID + "-css";
                        }
                        if (!viewConfig.cssUrl) {
                            viewConfig.cssUrl = 'views/' + viewConfig.ID + '/' + viewConfig.ID + '.css';
                        }

                        this.views.push(viewConfig);

                        $routeProvider.when(viewConfig.viewUrl, {
                            templateUrl: viewConfig.templateUrl,
                            controller: viewConfig.controller,
                            resolve: {
                                resolver: ['$q', '$timeout', function ($q, $timeout) {

                                    var deferred = $q.defer();
                                    if (angular.element("#"+viewConfig.cssId).length == 0) {
                                        var link = document.createElement('link');
                                        link.id = viewConfig.cssId;
                                        link.rel = "stylesheet";
                                        link.type = "text/css";
                                        link.href = viewConfig.cssUrl;
                                        angular.element('head').append(link);
                                    }
                                    if (viewConfig.requirejsConfig) {
                                        require.config(viewConfig.requirejsConfig);
                                    }
                                    require([viewConfig.requirejsName], function () {
                                        pluggableViews.registerModule(viewConfig.moduleName);
                                        $timeout(function() {
                                            deferred.resolve();
                                        });
                                    });
                                    return deferred.promise;
                                }]
                            }
                        });
                    };
                    this.$get = function () {
                        return {
                            views: pluggableViews.views,
                            registerModule: pluggableViews.registerModule,
                            registerView: pluggableViews.registerView,
                            toTitleCase: pluggableViews.toTitleCase
                        };
                    }
                }]);
    });
}());

This is an example of how to use this provider in order to register pluggable views:

(function () {
    'use strict';

    define([
        'angular',
        'ngRoute',
        'pluggableViews',
        'views/nav/nav'
    ], function (angular) {
        var app = angular.module('cards', [
            'ngRoute',
            'pluggableViews',
            'cards.nav'
        ]);
        app.directive('navbar', function () {
            return {
                restrict: 'E',
                templateUrl: '../views/nav/nav.html'
            };
        });
        app.config(['$routeProvider',
            '$pluggableViewsProvider',
            function ($routeProvider, $pluggableViewsProvider) {
                $pluggableViewsProvider.registerView({
                    ID: 'walls',
                    moduleName: "cards.walls",
                    requirejsConfig: {paths: {'walls': 'views/walls/walls'}}
                });
                $pluggableViewsProvider.registerView({
                    ID: 'admin',
                    moduleName: "cards.admin",
                    requirejsConfig: {paths: {'admin': 'views/admin/admin'}}
                });
                $pluggableViewsProvider.registerView({
                    ID: 'reports',
                    moduleName: "cards.reports",
                    requirejsConfig: {paths: {'reports': 'views/reports/reports'}}
                });

                $routeProvider.otherwise({redirectTo: '/walls'});
            }]);

        return app;
    });
}());

In each view (which needs the navigation bar), I use the navbar directive which template contains the following:

<ul class="nav navbar-nav" ng-controller="NavigationController">
    <li ng-repeat="view in views track by $index" ng-class="navClass(view.ID)"><a href='#{{view.viewUrl}}'>{{view.navigationText}}</a></li>
</ul>

And the NavigationController gives us access to the views configured:

'use strict';
define([
    'angular',
    'pluggableViews'
], function(angular) {
    angular.module('cards.nav', [
        'pluggableViews'
    ])
        .controller('NavigationController', ['$scope', '$location', '$pluggableViews', function ($scope, $location, $pluggableViews) {
            $scope.navClass = function (page) {
                var currentRoute = $location.path().substring(1) || 'home';
                return page === currentRoute ? 'active' : '';
            };

            $scope.views = $pluggableViews.views;
        }]);
});

In this example, the pluggable views registered are hardcoded in my application but having a separate file containing the view configuration is fairly easy and would be a way to make your views truly work as plugins.

So that’s it for this post. We’ve build an provider which allows us to register views on the fly considering their dependencies and allowing the navigation bar or panel to be extended. Of course, you’ll still need to add some error handling, some logic to make sure that modules you depend on do not get activated multiple times… But this is all for a next post.

6 thoughts on “AngularJS: RequireJS, dynamic loading and pluggable views

  1. wow..awesome tutorial…i was working on creating a framework to enable user to create web app with easy on demand file loading and this was what I was looking for thanks!! this really helped me.

  2. This is a great post. I was searching for similar solution for a long time. I would be great if you could share the complete working source code.

Leave a Reply

Your email address will not be published. Required fields are marked *