Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Lazy load AngularJS with Webpack

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.