When your SPA is growing its download time is getting longer and longer. That's not going hand in hand with better user experience (remainder: that's why we are doing SPAs). More code means bigger files and when minification isn't enough for you the only thing you can do for your user is stop making him to download whole app at once. Here lazy loading comes in handy. Instead downloading every file let your user download only files which he need NOW, at this very page.
So. How to teach your app lazy loading? It's basically splits into to two things. Putting your modules into smaller chunks and implementing some mechanism which allows to load those chunks on demand. Sounds like a lot of work, isn't it? It's not as long as you're using webpack which supports Code Splitting out of the box. In this post I assumed that you are familiar with webpack, but in case you wouldn't here is the introduction. To make a long story short we will also use AngularUI Router and ocLazyLoad.
The code is available at GitHub. Feel free to fork it.
Webpack configuration
Nothing special, really. Practically the only difference from what you can copy and paste form the docs is using ng-annotate
to keep our code clean and babel
to use some ECMAScript 2015 magic. If you are interested in ES6 look at the previous post. Although those things are awesome any of these isn't required to achieve lazy loading.
// webpack.config.js
var config = {
entry: {
app: ['./src/core/bootstrap.js'],
},
output: {
path: __dirname + '/build/',
filename: 'bundle.js',
},
resolve: {
root: __dirname + '/src/',
},
module: {
noParse: [],
loaders: [
{ test: /\.js$/, exclude: /node_modules/,
loader: 'ng-annotate!babel' },
{ test: /\.html$/, loader: 'raw' },
]
}
};
module.exports = config;
The app
The application module is the main file and it has to be included in the bundle.js
which is obligatory to download on every page. As you can see we're not going to load any heavy stuff except global dependencies. Instead of loading controllers lets load only routing configuration.
// app.js
'use strict';
export default require('angular')
.module('lazyApp', [
require('angular-ui-router'),
require('oclazyload'),
require('./pages/home/home.routing').name,
require('./pages/messages/messages.routing').name,
]);
Routing configuration
All lazy loading happens here, inside routing config. As I mentioned we're using AngularUI Router as whe want to have nested views. We have coupe of use cases. Whe can load the whole module (including sub states controllers) or load only one controller per state (not taking into account parent states controllers which are required anyway).
Load the whole module
When user enter the /home
path the browser will download the home module. It includes controllers for both home
and home.about
states. We're able to lazy load module using resolve
property of state config object. Thanks to require.ensure
webpack can create our first chuck with the home module. It'll be called 1.bundle.js
. Without $ocLazyLoad.load
we get an Argument 'HomeController' is not a function, got undefined
as just loading the file after bootstrap is not enough in angular word. $ocLazyLoad.load
makes possible to register a module after bootstrap and use it when it's loaded.
// home.routing.js
'use strict';
function homeRouting($urlRouterProvider, $stateProvider) {
$urlRouterProvider.otherwise('/home');
$stateProvider
.state('home', {
url: '/home',
template: require('./views/home.html'),
controller: 'HomeController as vm',
resolve: {
loadHomeController: ($q, $ocLazyLoad) => {
return $q((resolve) => {
require.ensure([], () => {
// load whole module
let module = require('./home');
$ocLazyLoad.load({name: 'home'});
resolve(module.controller);
});
});
}
}
}).state('home.about', {
url: '/about',
template: require('./views/home.about.html'),
controller: 'HomeAboutController as vm',
});
}
export default angular
.module('home.routing', [])
.config(homeRouting);
Controllers were added as module dependencies.
// home.js
'use strict';
export default angular
.module('home', [
require('./controllers/home.controller').name,
require('./controllers/home.about.controller').name
]);
Load only controller
What we have done is a one step forward, but let's do the next one. This time there'll be no big module, only lean controllers.
// messages.routing.js
'use strict';
function messagesRouting($stateProvider) {
$stateProvider
.state('messages', {
url: '/messages',
template: require('./views/messages.html'),
controller: 'MessagesController as vm',
resolve: {
loadMessagesController: ($q, $ocLazyLoad) => {
return $q((resolve) => {
require.ensure([], () => {
// load only controller module
let module = require('./controllers/messages.controller');
$ocLazyLoad.load({name: module.name});
resolve(module.controller);
})
});
}
}
}).state('messages.all', {
url: '/all',
template: require('./views/messages.all.html'),
controller: 'MessagesAllController as vm',
resolve: {
loadMessagesAllController: ($q, $ocLazyLoad) => {
return $q((resolve) => {
require.ensure([], () => {
// load only controller module
let module = require('./controllers/messages.all.controller');
$ocLazyLoad.load({name: module.name});
resolve(module.controller);
})
});
}
}
})
...
I believe it's nothing unexpected here, the rules stay the same.
Load views
Now let's leave controllers for a moment and focus on views. As you probably noticed we're embedding the views inside routing configuration. It wouldn't be a problem if we didn't put all routing configs inside bundle.js
… but we did. The case is not how to lazy load the routing config it's how to lazy load the views and this is deadly simple while using webpack.
// messages.routing.js
...
.state('messages.new', {
url: '/new',
templateProvider: ($q) => {
return $q((resolve) => {
// lazy load the view
require.ensure([], () => resolve(require('./views/messages.new.html')));
});
},
controller: 'MessagesNewController as vm',
resolve: {
loadMessagesNewController: ($q, $ocLazyLoad) => {
return $q((resolve) => {
require.ensure([], () => {
// load only controller module
let module = require('./controllers/messages.new.controller');
$ocLazyLoad.load({name: module.name});
resolve(module.controller);
})
});
}
}
});
}
export default angular
.module('messages.routing', [])
.config(messagesRouting);
Watch out for duplicated dependencies
Let's see how the messages.all.controller
and messages.new.controller
look like.
// messages.all.controller.js
'use strict';
class MessagesAllController {
constructor(msgStore) {
this.msgs = msgStore.all();
}
}
export default angular
.module('messages.all.controller', [
require('commons/msg-store').name,
])
.controller('MessagesAllController', MessagesAllController);
// messages.all.controller.js
'use strict';
class MessagesNewController {
constructor(msgStore) {
this.text = '';
this._msgStore = msgStore;
}
create() {
this._msgStore.add(this.text);
this.text = '';
}
}
export default angular
.module('messages.new.controller', [
require('commons/msg-store').name,
])
.controller('MessagesNewController', MessagesNewController);
The source of our problem is require('commons/msg-store').name
. It requires the msgStore
service which is used to share messages between controllers. This service is included in two bundles. In this with messages.all.controller
and in this with messages.new.controller
. Now it has nothing to do with optimization. How to fix that? Just add msgStore
as application module dependency. Although it's still not perfect, in majority of cases it's fair enough.
// app.js
'use strict';
export default require('angular')
.module('lazyApp', [
require('angular-ui-router'),
require('oclazyload'),
// msgStore as global dependency
require('commons/msg-store').name,
require('./pages/home/home.routing').name,
require('./pages/messages/messages.routing').name,
]);
Unit testing tip
Moving msgStore
as global dependency doesn't mean you should remove it from controllers. If you do that it "will work" until you write the test without mocking the dependency. In unit test you are requiring only controller not the whole application module.
// messages.all.controller.spec.js
'use strict';
describe('MessagesAllController', () => {
var controller,
msgStoreMock;
beforeEach(angular.mock.module(require('./messages.all.controller').name));
beforeEach(inject(($controller) => {
msgStoreMock = require('commons/msg-store/msg-store.service.mock');
spyOn(msgStoreMock, 'all').and.returnValue(['foo', 8]);
controller = $controller('MessagesAllController', { msgStore: msgStoreMock });
}));
it('saves msgStore.all() in msgs', () => {
expect(msgStoreMock.all).toHaveBeenCalled();
expect(controller.msgs).toEqual(['foo', 8]);
});
});
Hero image by Erik-Jan Leusink on Unsplash.