Monday, February 15, 2016

Pre-Loading AngularJS Controllers and View Templates

My goal is to asynchronously pre-load both view templates and controllers after the application is running and interacting with the user, but well before the router displays the views. In a "normal" load AngularJS eager-loads the controllers and lazy-loads the view templates. I also have a way to lazy-load the controllers, so both controllers and view templates will be loaded just-in-time.

So to achieve my goal first I am going to look at the normal eager-load/lazy-load scenario, and then build on that for a pure lazy-load scenario. That will become the foundation for achieving my goal of pre-loading both controllers and view templates before they are required, but after the application is running.

Normal Controller Loading

This really depends on what your definition of "normal" is, because I am going to use RequireJS to manage the individual scripts that make up the application. In a "traditional" AngularJS application I would load all of the scripts in the correct order in the HTML document. RequireJS helps manage the dependencies between the scripts, most importantly by allowing the dependencies to be define in each script. It also reduces the script tags to just one to bootstrap the application. If you want the details of using RequireJS with Angular I wrote another post about that: AngularJS + RequireJS.

Using RequireJS does not remove the possibility of deploying the application with a monolithic script, in fact it has a tool to combine the scripts. Often doing development with individual scripts is more manageable, but deploying with a monolithic script is cleaner. However a monolithic script can delay application startup, and in this case it would contrary to my stated goal.

My first example has two controllers and two templates and uses Angular-Route to manage them. The operations are logged to the console so that you can see the order in which they take place. I am just showing you enough here to build on as we go along. Angular is bootstrapped programmatically from main.js (not shown). The second controller and view look almost identical to the first. The project is available at the end of the article.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!DOCTYPE html>
<!--index.html -->
<html>
    <head>
        <title>AngularJS Asynchronous Loading - Normal Load</title>
        <base href="/" />
        <script src="components/requirejs/require.js" data-main="main.js"></script>
    </head>
    <body>
        <h1>AngularJS Asynchronous Loading - Normal Load</h1>
        <div data-ng-view>
        </div>
    </body>
</html>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// app-config
//

'use strict';

define( [ 'modules/app', 'controllers/PageOneController', 'controllers/PageTwoController' ], function (app) {
    
    app.config([ '$locationProvider', '$routeProvider', function ($locationProvider, $routeProvider) {
        
        $routeProvider
            
        .when('/pageone', {
            
            templateUrl: 'views/pageone.html',
            controller: 'PageOneController',
            controllerAs: 'vc'   
        })
        
        .when('/pagetwo', {
            
            templateUrl: 'views/pagetwo.html',
            controller: 'PageTwoController',
            controllerAs: 'vc'      
        })
        
        .otherwise('/pageone');
        
        $locationProvider.html5Mode(true);
    }]);
    
    app.run([ function () {
    }]);
});

1
2
3
4
5
6
<div>
    <!-- pageone.html -->
    
    <h2>Page One</h2>
    <button data-ng-click="vc.switchPage()">Page Two</button>
</div>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// PageOneController
//

'use strict';

define([ 'modules/app' ], function (app) {
    
    console.log('Loading PageOneController');
    
    app.controller('PageOneController', [ '$scope', '$location', '$log', PageoneController ]);
    
    function PageOneController($scope, $location, $log) {
        
        console.log('Instantiating PageoneController');
        
        this.$scope = $scope;
        this.$location = $location;
        this.$log = $log;
    }
    
    PageOneController.prototype = Object.assign(Object.create(Object.prototype), {
        
        switchPage: function () {
            
            this.$log.info('Switching to page two');
            this.$location.url('/pagetwo');
        }
    });
    
    return PageoneController;
});

When you run this application it displays the first page. Peeking at the console shows that the two controllers were loaded, but only the PageOneController has been instantiated.

1
2
3
Loading PageOneController
Loading PageTwoController
Instantiating PageOneController

If you look at what files were loaded the important point is that only the first html template has been loaded; the templateUrl property in the router configuration is clearly served by a lazy-load because the second template is not there, and the first template was loaded using an XHR request:
Flip-flop between the two views and you will find out that pagetwo.html has been retrieved (via XHR) and the PageTwoController has been instantiated.
Go back and forth between the views and you will find that new instances of the controllers are instantiated, but the templates fortunately have been cached by AngularJS; the data was retrieved using an XHR request and the files will not show up in the source view in developer tools.

1
2
3
4
5
6
7
8
9
Loading PageOneController
Loading PageTwoController
Instantiating PageOneController
Switching to page two
Instantiating PageTwoController
Switching to page one
Instantiating PageOneController
Switching to page two
Instantiating PageTwoController

So this is a normal load sequence: all of the controllers are loaded, one controller is initialized, the view template is loaded and the view displayed. The second template is not loaded until it is needed.

Eager-loading of of the controllers makes sure that everything is available when necessary, and if you need that you may want to ensure that everything is there by making a monolithic script for the application (RequireJS or Bowerify). But what if that eager-loading is slowing down the launch of the application? What if the users are frustrated waiting for things to start?

Lazy-loading Controllers

We just proved that AngularJS is already lazy-loading the template, but it will not lazy-load the controller all by itself. We can modularize our application to help manage development, and RequireJS will take care of making sure that dependencies are loaded, but how do we go about dynamically loading dependencies just as they are needed?

The definition for a route includes a "resolve" property. The resolve property is used to define additional dependencies that will be injected into the controller when it is instantiated. So those dependencies must be resolved before they can be injected, and we can leverage that for a lazy-load of the controller! For now I never actually use the injected value, I just rely on it being resolved.

In the following example I made four small changes to the app-config. On line 6 I removed the eager loading of the controllers by removing the dependencies in the AMD module (RequireJS) and I replaced them with an injection my new asyncControllerResolver provider. On line 8 I added the injection of $controllerProvider (we will come back to that), and on line 32 and line 40 I added to the route definitions a resolve property that calls a resolve method in the provider:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// app-config
//

'use strict';

define([ 'modules/app', 'modules/asyncControllerResolver' ], function (app) {
    
    app.config([ '$controllerProvider',
                 '$locationProvider',
                 '$routeProvider',
                 'asyncControllerResolverProvider', function ($controllerProvider, $locationProvider, $routeProvider, asyncControllerResolverProvider) {
        
        app.$controllerProvider = $controllerProvider;
        
        $routeProvider
            
        .when('/pageone', {
            
            templateUrl: 'views/pageone.html',
            controller: 'PageOneController',
            controllerAs: 'vc',
            resolve: asyncControllerResolverProvider.resolve('controllers/PageOneController')
        })
            
        .when('/pagetwo', {
            
            templateUrl: 'views/pagetwo.html',
            controller: 'PageTwoController',
            controllerAs: 'vc',
            resolve: asyncControllerResolverProvider.resolve('controllers/PageTwoController')
        })
        
        .otherwise('/pageone');
        
        $locationProvider.html5Mode(true);
    }]);
    
    app.run([ function () {  
    }]);
});

The provider's job is to use RequireJS to dynamically load the controller module, which is done with a require call on line 37 in the code below. But require is asynchronous, it returns immediately. The _resolveController method on line 33 takes care of that by wrapping the require in a promise created with $q. The promise is resolved in the require callback function when the module load is complete. When Angular-Route is provided with a promise as what to inject, it waits for the promise to be resolved and injects the result.

Since $q is needed it has to be injected at run, not at config. The object that resolve needs defines one or more properties to inject into the controller, and the property values can be injections. On line 24 we solve the problem by injecting $q into a function that passes it to _resolveController.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// asnycControllerResolver
//

'use strict';

define([ 'modules/app', 'assign' ], function (app) {
    
    app.provider('asyncControllerResolver', [ AsyncControllerResolver ]);
    
    function AsyncControllerResolver() {
    }
    
    AsyncControllerResolver.prototype = Object.assign(Object.create(Object.prototype), {
        
        $get: function () {

            return this;
        },
        
        resolve: function (controllerPath) {
            
            var resolveDef = {
                
                load: ['$q', (function ($q) {

                    return this._resolveController($q, controllerPath);
                }).bind(this)]
            };
            
            return resolveDef;
        },

        _resolveController: function ($q, controllerPath) {

            return $q(function (resolve, reject) {

                require([ controllerPath ], function () {

                    resolve();
                });
            });
        }
    });
});

I have a new problem in the controller module because the controller method of the application module does not work once we enter the run phase. It does not complain, but the controller will not be registered. You can register a controller in the run phase with $controllerProvider, but you can only inject $controllerProvider at config! I fixed the problem by injecting $controllerProvider into the app-config and adding it as a property of the application module so I can refer to it later in the controller module. So app.$controllerProvider.register on line 10 will register a new controller dynamically while the application is running:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// PageOneController
//

'use strict';

define([ 'angular', 'modules/app', 'assign' ], function (angular, app) {
    
    console.log('Loading PageOneController');

    app.$controllerProvider.register('PageOneController', [ '$scope', '$location', '$log', PageOneController ]);

    function PageOneController($scope, $location, $log) {

        console.log('Instantiating PageOneController');

        this.$scope = $scope;
        this.$location = $location;
        this.$log = $log;
    }

    PageOneController.prototype = Object.assign(Object.create(Object.prototype), {

        switchPage: function () {

            this.$log.info('Switching to page two');
            this.$location.url('/pagetwo');
        }
    });
    
    return PageOneController;
});

Looking at what happened during the load, the PageOneController is now the last thing that was loaded and it was RequireJS that loaded it when it was needed. So it is a lazy-load. PageTwoController has not been loaded, but it will be lazy-loaded if the user moves to that view. The console log verifies what happened.

1
2
Loading PageOneController
Instantiating PageOneController

So now we have reversed the problem from the normal load. The startup is faster, but what if the user is seeing a hang while the template and controller are retrieved as they are needed?

Pre-Loading Both Templates and Controllers

At this point the templates are always lazy-loaded by AngularJS, and I have a choice for the controller: eager-load it or lazy-load it. The results I really want are different: to minimize any delay the user sees when starting the application and switching. I need to pre-load both the view templates and controllers after the application has bootstrapped, but before they are needed.

Well that is not possible for the initial view, we probably will have to wait it to load. But at least I can do something about the views that follow it. I could treat the initial view as special and eager-load just that, but then that would be a disruption in my pattern for loading views. And it turns out that it really is not important, eager-loading it or pre-loading it takes the same amount of time.

Pre-loading Controllers

So to control loading both the template and controller I decided it was best to move the definition of the route into the provider. Now the app-config calls the provider to get a route definition object. I did change the name of the provider to reflect its new purpose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// app-config
//

'use strict';

define( [ 'modules/app', 'modules/asyncRouteResolver' ], function (app) {
    
    app.config([ '$controllerProvider',     
                 '$locationProvider',
                 '$routeProvider',
                 'asyncRouteResolverProvider',
                 function ($controllerProvider, $locationProvider, $routeProvider, asyncRouteResolverProvider) {
        
        app.$controllerProvider = $controllerProvider;
        
        $routeProvider
            
        .when('/pageone', asyncRouteResolverProvider.resolve('pageone', 'PageOneController', 'vc'))
            
        .when('/pagetwo', asyncRouteResolverProvider.resolve('pagetwo', 'PageTwoController', 'vc'))
        
        .otherwise('/pageone');
        
        $locationProvider.html5Mode(true);
    }]);
    
    app.run([ function () {  
    }]);
});

The provider needs the name of the view, the name of the controller, and optionally the name of the controllerAs property the new route object will have.  I could have added the path to the view template file and the path to the  controller module, but I expect that they will almost always be in the same place. So I added a configuration method to the provider that allows the application to set that location. The default paths are the views and controller folders at the application root, so I do not need to change the configuration in my example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// asnycRouteResolver
//

'use strict';

define([ 'modules/app', 'assign', 'directives/ngxTemplate' ], function (app) {
    
    app.provider('asyncRouteResolver', [ AsyncRouteResolver ]);
    
    function AsyncRouteResolver() {
        
        this._config = {
            
            controllersPath: 'controllers/',
            viewsPath: 'views/'
        }
    }
    
    AsyncRouteResolver.prototype = Object.assign(Object.create(Object.prototype), {
        
        config: function (configObject) {
            
            this._setConfig(this._config, configObject);
        },
          
        _setConfig: function (target, configObject) {
            
            if (configObject) {

                if (configObject.controllersPath) {

                    target.controllersPath = configObject.controllersPath + '/';
                }

                if (configObject.viewsPath) {

                    target.viewsPath = configObject.controllersPath + '/';
                }
            }
        },
  
        ...

Forcing the controller module to preload is very simple. All I had to do was add a call to make RequireJS load it immediately when the route definition is made, instead of delaying it until Angular-Route runs the function to create the promise. RequireJS manages modules very nicely, if it has loaded by the time the promise is used the promise will return immediately. If it is still pending then the promise will wait:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
        resolve: function (viewName, controllerName, controllerAs, config) {
            
            var localConfig = { };
        
            Object.assign(localConfig, this._config);
            this._setConfig(localConfig, config);
            
            // Build the paths to the modules.
            
            var controllerPath = this._stripDoubleSlashes(localConfig.controllersPath + controllerName);
            var viewPath = this._stripDoubleSlashes(localConfig.viewsPath + viewName + '.html');

            require([ 'text!' + viewPath, controllerPath ], function (viewTemplate) {
                
                console.log('Completed dependency load of', viewPath, 'and', controllerPath);
            });

            var routeDef = {
                
                template: '<div data-ngx-template="$resolve.load"></div>',
                controller: controllerName,
                resolve: {

                    load: ['$q', (function ($q) {

                        // This was all just to inject $q into a method to have a promise for loading the modules.
                        
                        return this._resolveController($q, viewPath, controllerPath);
                    }).bind(this)]
                }
            };
            
            if (controllerAs) {
                
                routeDef.controllerAs = controllerAs;
            }

            return routeDef;
        },

The definition of _resolveController did not fundamentally change, and we will visit that in a moment. What you can see in the code above is that on line 1 I added a config parameter to the provider method that allows the paths to be overridden on a route by route basis. That is just a nicety, and it explains why I build the _setConfig method in the previous block of code.

The important part is on line 13. A require call is being used to start a load of both the view template and the controller module immediately before the route definition is built. I will come back to the view template in a moment.

On line 18 instead of building a resolve object as the earlier provider did, this one builds a whole route definition. The controller is the controller name that will be registered and the controllerAs is handled on line 33 in an if statement in case it was left undefined. The template is what looks strange because I hardwired some HTML into it.

Pre-loading Templates

First of all if you look back to line 20 in the previous block you will see an ngxTemplate directive that has as its value $resolve.load. Angular-Route will store the resolve object as $resolve in the scope, and the load property (defined by me in the code) will be resolved to the resolution of the promise which pre-loads the modules. In this case, I made that resolution the text that was loaded for the view template file. Now I have it available to pass to that directive.

It turns out that I have three new obstacles to overcome in order to get around AngularJS' lazy-load of the templates.

The first problem is how to load a text file instead of a module. That one is an easy fix, I will include the requirejs/text plugin in the application from https://github.com/requirejs/text. I added it under the components/requirejs folder and aliased it as "text" in the RequireJS configuration in main.js:

1
2
3
4
5
6
7
        paths: {
            'angular' : 'components/angular/angular',
            'angular-resource': 'components/angular-resource/angular-resource',
            'angular-route': 'components/angular-route/angular-route',
            'assign': 'components/object-assign-shim',
            'text': 'components/requirejs/text'
        },

You can see where I used it to load the template on line 13 of the previous code block, and I will do the same thing in the _resolveController method in a moment.

The second obstacle is that Angular-Route does not evaluate the template or the templateUrl properties when it presents the view, the properties are evaluated and cached when the route definition object is set. That is just nasty for my purposes.

The way around that problem is to use a static HTML template where the actual template will be injected at the time the view is displayed. But that runs directly into the third obstacle...

I have to get AngularJS to compile the pre-loaded template when before the view is displayed. And to do that, I need to get it through the compiler, $compile. And that means that $compile needs to be injected somewhere at runtime.

Since I have to inject the compiled HTML into the template skeleton, the tool to use is a directive. It can handle the injection of $compile, compiling the loaded template, and injecting it where it belongs!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ngxTemplate
//

'use strict';

define([ 'modules/app' ], function (app) {
    
    app.directive('ngxTemplate', [ '$compile', function ($compile) {
        
        var directiveDefinition = {
            
            restrict: 'A',
            replace: true,
            link: function (scope, element, attributes) {
                
                scope.$watch(attributes.ngxTemplate, function (html) {
                    
                    element.html(html);
                    $compile(element.contents())(scope);
                })
            }
        }
        
        return directiveDefinition;
    }]);
})

That takes care of everything. Look at what happens when the application is loaded. Both controllers are pre-loaded, but this time RequireJS has loaded them after the other modules. And the templates are also pre-loaded by the text plugin, where before AngularJS lazy-loaded them:


The console also reflects the order in which modules were loaded and controllers instantiated:

1
2
3
4
5
6
Loading PageOneController
Loading PageTwoController
Completed dependency load of views/pageone.html and controllers/PageOneController
Resolved promise to load views/pageone.html and controllers/PageOneController
Completed dependency load of views/pagetwo.html and controllers/PageTwoController
Instantiating PageOneController

The provider is a bit longer than it was before, but here it is in its complete form. Of course you can download the entire project (with commented code) at the end of this article.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
// asnycRouteResolver
//

'use strict';

define([ 'modules/app', 'assign', 'directives/ngxTemplate' ], function (app) {
    
    app.provider('asyncRouteResolver', [ AsyncRouteResolver ]);
    
    function AsyncRouteResolver() {
        
        this._config = {
            
            controllersPath: 'controllers/',
            viewsPath: 'views/'
        }
    }
    
    AsyncRouteResolver.prototype = Object.assign(Object.create(Object.prototype), {
        
        config: function (configObject) {
            
            this._setConfig(this._config, configObject);
        },
        
        $get: function () {
            
            return this;
        },
        
        resolve: function (viewName, controllerName, controllerAs, config) {
            
            var localConfig = { };
        
            Object.assign(localConfig, this._config);
            this._setConfig(localConfig, config);
            
            var controllerPath = this._stripDoubleSlashes(localConfig.controllersPath + controllerName);
            var viewPath = this._stripDoubleSlashes(localConfig.viewsPath + viewName + '.html');

            require([ 'text!' + viewPath, controllerPath ], function (viewTemplate) {
                
                console.log('Completed dependency load of', viewPath, 'and', controllerPath);
            });

            var routeDef = {
                
                template: '<div data-ngx-template="$resolve.load"></div>',
                controller: controllerName,
                resolve: {

                    load: ['$q', (function ($q) {
                        
                        return this._resolveController($q, viewPath, controllerPath);
                    }).bind(this)]
                }
            };
            
            if (controllerAs) {
                
                routeDef.controllerAs = controllerAs;
            }

            return routeDef;
        },

        _resolveController: function ($q, viewPath, controllerPath) {

            return $q(function (resolve, reject) {

                require([ 'text!' + viewPath, controllerPath ], function (viewTemplate) {

                    console.log('Resolved promise to load', viewPath, 'and', controllerPath);
                    resolve(viewTemplate);
                });
            });
        },
        
        _setConfig: function (target, configObject) {
            
            if (configObject) {

                if (configObject.controllersPath) {

                    target.controllersPath = configObject.controllersPath + '/';
                }

                if (configObject.viewsPath) {

                    target.viewsPath = configObject.controllersPath + '/';
                }
            }
        },
        
        _stripDoubleSlashes: function (path) {
            
            return path.replace(/\/\//, '/');
        }
    });
});

Extending the Results

I demonstrated how to do this for controllers, but delaying the load of any module is possible using similar techniques. Since a major grouping in the application is by a view and its dependencies, most of the time you will want to extend this example to include other pre-loaded modules: providers, services, factories, etc. The late-registration problem exists for these too, so look to the $compileProvider.directive(), $filterProvider.register(),  $provide.factory(), and $provide.service() methods to handle that.

You can download the three working projects for this post at http://askjoelit.com/download/AngularJsPreloadModules.zip. There are three AngularJS projects in the zip file, and each project has a node application to serve the files. In each project make sure that you run "npm install" first to bring in the dependent Node modules and "bower install" to bring in the client dependencies before launching the service with "npm start." Installing NodeJS and Bower (a node application) is required to run the examples.

References

See the references page.

No comments:

Post a Comment