Dynamically Loading Controllers and Views with AngularJS/$controllerProvider and RequireJS
Dynamically Loading Controllers and Views
Updated: August 30th, 2014
A complete sample application that uses the techniques shown in this post can be found at https://github.com/DanWahlin/CustomerManager.
AngularJS provides a simple way to associate a view with a controller and load everything at runtime using the $routeProvider object. Routing code is typically put in a module’s config() function and looks similar to the following:
$routeProvider .when('/customers', { controller: 'CustomersController', templateUrl: '/app/views/customers.html' }) .when('/customerorders/:customerID', { controller: 'CustomerOrdersController', templateUrl: '/app/views/customerOrders.html' }) .when('/orders', { controller: 'OrdersController', templateUrl: '/app/views/orders.html' }) .otherwise({ redirectTo: '/customers' });
While this type of code works great for defining routes it requires controller scripts to be loaded upfront in the main shell page by default. That works fine in some scenarios but what if you have a lot of controller scripts and views in a given application and want to dynamically load them on-the-fly at runtime? One way of dealing with that scenario is to define a resolve property on each route and assign it a function that returns a promise. The function can handle dynamically loading the script containing the target controller and resolve the promise once the load is complete. An example of using the resolve property is shown next:
$routeProvider .when('/customers', { templateUrl: '/app/views/customers.html', resolve: resolveController('/app/controllers/customersController.js') });
This approach works well in cases where you don’t want all of your controller scripts loaded upfront, but it still doesn’t feel quite right – at least to me. I personally don’t like having to define two paths especially if you’ll be working with a lot of routes. If you’ve ever worked with a framework that uses convention over configuration then you’ll know that we can clean up this code by coming up with a standard convention for naming views and controllers. Coming up with a convention can help simplify routes and maintenance of the application over time. The approach that I’ll demonstrate in this post uses the following routing code to define the path, view and controller:
$routeProvider .when('/customers', route.resolve('Customers')) .when('/customerorders/:customerID', route.resolve('CustomerOrders')) .when('/orders', route.resolve('Orders')) .otherwise({ redirectTo: '/customers' });
Notice that a single value is passed into the route.resolve() function. Behind the scenes the function will automatically create the path to the view and the path to the controller based on some simple conventions and then load the appropriate files dynamically. You can access a sample (work in progress) project at https://github.com/DanWahlin/CustomerManager. Let’s take a look at how it works.
Dynamically Loading Controllers
The following diagram shows the different players involved in simplifying routes and dynamically loading controllers. RequireJS is used to dynamically load controller JavaScript files and make an application’s main module available to the controllers so that they’re registered properly after they’re loaded.
Here’s how it works:
- A file named main.js defines custom scripts that will be loaded using RequireJS. I originally defined 3rd party libraries such as AngularJS in main.js as well but decided there simply wasn’t enough benefit over loading them at the bottom of the initial page using a <script> tag and pulled them out as a result. Only custom scripts are added into main.js in the sample application.
- The routeResolver.js script creates an AngularJS provider. It’s loaded by RequireJS and used in app.js within the config() function to define routes and resolve them dynamically at runtime.
- The app.js file is loaded by RequireJS. It defines the main AngularJS module used in the application as well as the config for the module. The config section accesses compiler providers (such as $controllerProvider) needed to dynamically load controllers, directives, etc. and also uses the routeResolver to define views and controllers that need to be dynamically loaded (see diagram above). Views and routes are defined using a single name. For example, route.resolve(“Customers”) will cause customers.html to be loaded from the views folder and customersController.js from the controllers folder.
- Each controller is passed the AngularJS module created in app.js by RequireJS. This allows the controllers to dynamically register themselves with AngularJS using a $controllerProvider behind the scenes.
- At runtime the routes defined in app.js are dynamically resolved using the routeResolver provider that is injected into the config() function located in app.js.
Let’s break down each of the files one by one now that you’ve seen how they integrate with each other.
Defining Scripts in main.js
RequireJS is used to load all of the custom scripts used in the sample application. It’s useful to ensure dependencies are loaded in the proper order and also helps dynamically loaded controllers, directives and other AngularJS objects easily access the application’s module so that they can be properly registered. An example of the main.js file used to configure RequireJS is shown next. All third party scripts are loaded directly in the shell page (index.html) rather than using RequireJS. Services used across multiple controllers are loaded upfront as well as some controllers needed immediately. However, the services could certainly be loaded dynamically if desired with a little more work (that feature isn’t currently implemented).
require.config({ baseUrl: '/app', urlArgs: 'v=1.0' }); require( [ 'app', 'services/routeResolver', 'services/config', 'services/customersBreezeService', 'services/customersService', 'services/dataService', 'controllers/navbarController', 'controllers/orderChildController' ], function () { angular.bootstrap(document, ['customersApp']); });
Since the app.js file is loaded dynamically (it defines the main application module) an ng-app=”customersApp” attribute isn’t hard-coded into the shell page that initially loads as with standard AngularJS applications. Instead, it’s added at runtime by calling angular.bootstrap().
The routeResolver.js File
The routeResolver.js file handles creating an AngularJS module and provider that can dynamically load views and controllers. AngularJS already comes with built-in support for loading views dynamically and with a little more work controllers can be loaded dynamically as well. Loading controller scripts can be done by assigning the resolve property mentioned earlier to a function that handles loading the controller. What’s unique about routeResolver is that it doesn’t accept hard-coded paths to the target view or controller. Instead, you define a base name such as “Customers” and the resolver will generate the path to the appropriate view and controller based on a standard convention.
Here’s the complete code for the routeResolver provider as well as a routeConfig object used to configure view and controller directories. The code starts by defining a new AngularJS module named routeResolverServices. A provider was chosen (as opposed to a service or factory) because the routeResolver needs to be injected into the config() function of a given application’s module (in app.js for example). The config() function uses the routeResolver to define the routes based on the conventions mentioned earlier.
'use strict'; define([], function () { var routeResolver = function () { this.$get = function () { return this; }; this.routeConfig = function () { var viewsDirectory = '/app/views/', controllersDirectory = '/app/controllers/', setBaseDirectories = function (viewsDir, controllersDir) { viewsDirectory = viewsDir; controllersDirectory = controllersDir; }, getViewsDirectory = function () { return viewsDirectory; }, getControllersDirectory = function () { return controllersDirectory; }; return { setBaseDirectories: setBaseDirectories, getControllersDirectory: getControllersDirectory, getViewsDirectory: getViewsDirectory }; }(); this.route = function (routeConfig) { var resolve = function (baseName, path, secure) { if (!path) path = ''; var routeDef = {}; routeDef.templateUrl = routeConfig.getViewsDirectory() + path + baseName + '.html'; routeDef.controller = baseName + 'Controller'; routeDef.secure = (secure) ? secure : false; routeDef.resolve = { load: ['$q', '$rootScope', function ($q, $rootScope) { var dependencies = [routeConfig.getControllersDirectory() + path + baseName + 'Controller.js']; return resolveDependencies($q, $rootScope, dependencies); }] }; return routeDef; }, resolveDependencies = function ($q, $rootScope, dependencies) { var defer = $q.defer(); require(dependencies, function () { defer.resolve(); $rootScope.$apply() }); return defer.promise; }; return { resolve: resolve } }(this.routeConfig); }; var servicesApp = angular.module('routeResolverServices', []); //Must be a provider since it will be injected into module.config() servicesApp.provider('routeResolver', routeResolver); });
Looking through the code you can see that a routeConfig object is defined that allows the views and controllers directory to be set using code similar to the following (this code goes in the module.config() function that’ll be shown later):
//Change default views and controllers directory using the following: routeResolverProvider.routeConfig.setBaseDirectories('/app/myViews', '/app/myControllers');
The routeConfig object defaults to the following directories:
Views: /app/views
Controllers: /app/controllers
In addition to routeConfig you’ll also find a route object with a resolve() function that handles dynamically loading controller scripts. It handles setting the templateUrl, controller name, any folders where controllers should be located, and security for a given route. It also takes advantage of AngularJS’s resolve property to dynamically load the target controller script using RequireJS. The resolve() function shown above delegates loading controller scripts to another function named resolveDependencies() which handles getting a deferral, returning a promise, and resolving the deferral once the controller script loads.
The path parameter in resolve() is optional but can be used to pass a subfolder in cases where controllers aren’t located in the root controllers folder. This way you can pass in a variety of paths which is useful in large applications where controllers are grouped into subfolders. An example of defining a route that has a subfolder for the controller is shown in the Defining Routes in app.js section below.
The secure property (see routeDef.secure above) can be used to mark if a route requires security/authentication. This is a primitive implementation but could easily be extended to check for any roles passed down to the client from the server. Keep in mind that it’s only a client-side check. A server-side security check is ALWAYS required when any secured resource is called since a hacker could easily tweak the JavaScript data.
Defining Routes in app.js
The app.js file defines the application’s main module and also handles configuring routes. It’s wrapped with a define() call to RequireJS to ensure that the routeResolver is loaded and available. The customersApp module defined in app.js requires routeResolverServices.
The config() function has several different providers injected into it including the routeResolver provider (note that the actual name of the provider is routeResolver but that you must add “Provider” on the end of the name for it to work properly when injecting it). The AngularJS providers that are injected into config() are used to dynamically register controllers, directives, filters and more after a given script is dynamically loaded.
Notice that within the config() function an object literal is assigned to app.register (app represents the application’s module). The object literal contains properties that can be used to dynamically register a controller and other AngularJS objects that are downloaded on-the-fly (thanks to Mateusz Bilski for the initial code that started me thinking about this more). You’ll learn more about that process in a moment. Application routes are defined using the routeResolver provider’s route.resolve() function which accepts the base name to use to lookup views and controllers as mentioned earlier based on conventions.
'use strict'; define(['services/routeResolver'], function () { var app = angular.module('customersApp', ['routeResolverServices']); app.config(['$routeProvider', 'routeResolverProvider', '$controllerProvider',
'$compileProvider', '$filterProvider', '$provide', function ($routeProvider, routeResolverProvider, $controllerProvider,
$compileProvider, $filterProvider, $provide) { //Change default views and controllers directory using the following: //routeResolverProvider.routeConfig.setBaseDirectories('/app/views', '/app/controllers'); app.register = { controller: $controllerProvider.register, directive: $compileProvider.directive, filter: $filterProvider.register, factory: $provide.factory, service: $provide.service }; //Define routes - controllers will be loaded dynamically var route = routeResolverProvider.route; $routeProvider .when('/customers', route.resolve('Customers')) .when('/customerorders/:customerID', route.resolve('CustomerOrders')) .when('/orders', route.resolve('Orders')) .otherwise({ redirectTo: '/customers' }); }]); return app; });
Here’s an example of supplying custom subfolders for controllers in situations where they don’t live in the root “controllers” folder. The code also shows how a route can be secured by passing true in for the 3rd parameter:
$routeProvider .when('/customers', route.resolve('Customers', 'customers/')) .when('/customerorders/:customerId', route.resolve('CustomerOrders', 'customers/')) .when('/customeredit/:customerId', route.resolve('CustomerEdit', 'customers/', true)) .when('/orders', route.resolve('Orders', 'orders/')) .when('/about', route.resolve('About')) .when('/login/:redirect*?', route.resolve('Login')) .otherwise({ redirectTo: '/customers' });
Note that the customeredit route definition passes a value of true into the resolve() function. That will mark the route as requiring authentication (something discussed earlier).
To check if a route has the secure property set to true and redirect to a login view the following code can be used (I normally put this in the app.js file):
app.run(['$q', 'use$q', '$rootScope', '$location', 'authService', function ($q, use$q, $rootScope, $location, authService) { ... //Client-side security. Server-side framework MUST add it's //own security as well since client-based security is easily hacked $rootScope.$on("$routeChangeStart", function (event, next, current) { if (next && next.$$route && next.$$route.secure) { if (!authService.user.isAuthenticated) { authService.redirectToLogin(); } } }); }]);
Notice that the secure property added into a given route is checked using the next object passed into the $routeChangeStart event. If security is required (which happens when secure is true) and the user hasn’t already logged in they’ll be redirected to a login page. The authService object handles that in this particular case – a link to a full sample application can be found at the end of the post. I want to re-emphasize that this is only to allow for quick client-side redirects where a client tries to get to a secured resource but hasn’t logged in yet. Always, always, always revalidate credentials on the server-side for any secured resources. You’ve been warned (multiple times)! :-)
Defining Controllers
Controllers in the application rely on RequireJS to access the object representing the application’s module and then access the register property shown earlier to register a controller script with AngularJS. This type of registration is required since using the standard angular.module(“ModuleName”).controller() code won’t work properly with dynamically loaded controller scripts (at least at the current time). An example of a controller named customersController.js is shown next. Notice that it uses RequireJS’s define() function to get to the app object and then uses it to register the controller. The app.register.controller() function points to AngularJS’s $controllerProvider.register() function behind the scenes as shown earlier with app.js. All of the controllers in the application follow this pattern.
'use strict'; define(['app'], function (app) { //This controller retrieves data from the customersService and associates it with the $scope //The $scope is ultimately bound to the customers view due to convention followed by the routeResolver app.register.controller('CustomersController', ['$scope', 'config', 'dataService',
function ($scope, config, dataService) { //Controller code goes here
}]); });
What about Directives, Filters and other Types?
The current routeResolver implementation only supports dynamically loading controllers. However, all of the necessary plumbing is in place to support the dynamic loading of directive, filter, service/factory, and other scripts.
Summary
If you’re new to AngularJS this may seem like a lot of code to perform a simple task like dynamically downloading a controller script. However, once you get the main.js and app.js scripts in place the rest takes care of itself if you follow the pattern outlined above for defining controllers.
The word on the street is that AngularJS will eventually provide a built-in way to dynamically load controllers and other scripts. I’ll certainly welcome that feature when it’s available but until then the routeResolver and RequireJS functionality shown here gets the job done. Although I’ve been through several iterations and variations of this code I expect it’ll change as I use it more and get feedback. Access the sample (work in progress) project that shows all of this in action at https://github.com/DanWahlin/CustomerManager.