Over the past year, my team has been working on a modular dashboard built as a single-page AngularJS application bundled together using webpack. But the project has gone through some transformation to get to where it is today, as we migrated the application into its current modularized form.

When our project began, the dashboard was a relatively small application (judging by the number of source files, at least). We quickly and easily wrote JavaScript in immediately invoked function expressions (IIFEs), and added this source code to our dashboard markup through <script> tags. The build process produced a single, minified file.

By modularizing your source, you create decoupled pieces of code that perform better and are easier to maintain as the project grows.

This pattern was familiar to many of us, and it worked pretty well. But we could foresee some issues: What would happen when scripts needed to be removed or consolidated, or weren’t needed when the page loaded — or what if they were needed by a different page entirely? Who would remember how these dependencies were all interconnected?

To address these issues, we decided to modularize the application by updating the source code and using the webpack module bundler. By modularizing your source, you create decoupled pieces of code that perform better and are easier to maintain as the project grows. Without modularity, you’re typically stuck maintaining a long list of <script> tags, trying to remember to remove the ones that are no longer used, or making sure that you reference the right scripts when you add new code. With modularization, you can grab subsets of the dependency graph and reuse it in other places. Furthermore, modularization allows for the possibility of lazy-loading features, leading to a highly optimized experience for users.

This tutorial recaps our modularization experience to guide you through the process of setting up a modularized Angular application — whether you’re modularizing from scratch or migrating an existing application.

Picking a module bundler

It would be great if native browser modules existed and you could eliminate the module-bundling step from the build, but for now, you need to use a module bundler as part of your build flow. Available bundlers include webpack, RequireJS, Browserify, and Rollup, to name a few.

Each bundler has advantages and disadvantages, but they all similarly perform the task of traversing your source code’s dependency tree to produce a minimal set of JavaScript files needed to run your application. For example, suppose you have an a.js file that has a dependency on code that you’ve written in b.js and c.js. Based on your module format (see Picking a module format), you define these interfile dependencies. The bundler then produces a single file that has the contents of a.js, b.js, and c.js. If the code that brought in the dependency on b.js is removed from a.js, and the bundle is built again, it then contains the contents of a.js and c.js. only.

We settled on webpack as the module bundler for our project, based on its rich plugin ecosystem, active community, flexibility in module format support, performance, and ability to produce multiple bundles.

Picking a module format

When deciding on a module format, we confronted a long-standing debate: CommonJS versus asynchronous module definition (AMD). Many of us were familiar with AMD, but we chose CommonJS because more team members had experience with it through use of Node.js. We sacrificed AMD’s asynchronous flow for the terse, synchronous syntax of CommonJS, knowing that our end result would be a minimal number of bundled modules. Fortunately, webpack understands both of these formats out of the box, so if any of the libraries that we used bundled themselves using AMD, our code would continue to work.

Configuring webpack

With our bundler and module format selected, we moved on to setting up webpack. Our build process makes heavy use of Grunt, so it made sense for us to continue using Grunt when we began incorporating webpack. Installing webpack — locally, at least — is essential; the grunt-webpack plugin is handy when you’re using Grunt in your build stack. To install both, run the command:

npm install --save-dev grunt-webpack webpack

The configuration of webpack dictates most of what the bundler does when it begins processing the modules that you throw its way. The various configuration properties typically go in a webpack.config.js file.

We defined two configurations: development and distribution. They share many common properties; the difference is that development turns on source mapping to assist in debugging, and distribution minifies the resulting bundles to cut down on the size of the files transferred to the user.

Going over the entirety of webpack’s configuration options and our choices for each would be too large an exercise. Instead, I’ll explain just the options that were of interest for the purposes of the project.

Entry points

The entry to the application defines which bundles will be output by webpack and is required for webpack to start its process. Starting from this entry point, webpack walks the dependency hierarchy, building a map of dependencies so that when a module needs another, the necessary module is loaded in place.

You can define multiple entries, as we did in our project. The primary entry point is our application, which defines the dashboard’s module. This module then requires all of the other modules, each of which requires all of the services, factories, directives, and controllers. The other entry point is vendor. This entry point is isolated with third-party libraries that we use in the project. It was advantageous to break this entry point out into its own bundle, since the libraries themselves don’t change much, but our application does. If we had bundled up everything into one bundle, it would contain the changed application code plus the unchanged vendor code, thus inflating the download size because browser caching is not being leveraged properly.

Lazy loading

Sometimes a user doesn’t need a feature, or comes across it well after the initial page load. To deliver a better-performing experience, webpack allows lazy loading modules to initialize at the point that they’re needed. We applied this capability to the analytics aspects of our dashboard. The data visualization libraries are fairly large, so removing that content from the initial download can lead to a faster start for a user who’s not concerned with analytics. We relied on required.ensure to deliver this experience to users. In our original chart directive, the following code caused the application bundle to include c3 and all of its dependencies:

link: function () {
  var c3 = require('c3');
  // Use c3
}

We changed the directive to:

link: function () {
  require.ensure([], function ()
    var c3 = require('c3');
    // Use c3
  }, 'charts');
}

Now webpack creates a new charts bundle that’s downloaded only when the directive is linked.

ProvidePlugin

The webpack ProvidePlugin — a plugin that replaces occurrences of global variables with explicit exports of an associated loaded module — proved essential for our retrofit. Because our application was not modularized for most of the development cycle, it included many global references to angular, $, moment, and other libraries — for example:

moment().add(2, 'days');

ProvidePlugin changed the preceding code to:

require('moment')().add(2, 'days');

Use of ProvidePlugin spared us the need to find and replace all occurrences of these global variables over numerous files.

html-loader

One of webpack’s benefits is its ability to transform non-JavaScript content into a JavaScript module so that it can be required like any other JavaScript resource. webpack loaders are available for many kinds of tasks; we took advantage of html-loader. Without html-loader, we would have needed a build step to search for all HTML files and inject them into the Angular $templateCache, so that when directives used the templateUrl property, the HTML could be found.

Going through all of the directives and replacing templateUrl: '/directive/markup.html' with require('./markup.html') took quite a bit of work, but the end result is much easier to maintain. And now we’d know during development if we referenced the template incorrectly, instead of finding out during a build that the referenced path was off by one directory level.

To use html-loader in your project, install it by running the command:

npm install --save-dev html-loader

Creating the dependencies

Since we had an established code base by the time we modularized our source, nearly every source file needed to be modified in some way. We didn’t want our changes to kick up a lot of dust, so we established a pattern: The file that defined an Angular module would load all source files associated with the module; each source file would then require that parent module. Even when only a single factory or directive was required, its associated module would therefore be defined first. This approach worked well because each Angular module was already broken out into its own directory, with directives, services, and so on all falling under directories within that directory.

Here’s the general pattern that we followed in the module definition file:

var angular = require('angular');
var load = require.context('./', true, /\.js$/);
load.keys().forEach(load);
module.exports = angular.module('pi.utils', []).name;

This pattern uses webpack’s [require.context](https://webpack.js.org/api/module-methods/#requirecontext) API to build a list of files to load as modules. We could then keep an up-to-date list of resources that the module maintains without having to manually maintain a list of requires. We then exported the Angular module name from the module.

In our factory, directive, and controller files, we followed a pattern of requiring the file in which the Angular module was defined, which exports the Angular module’s name:

var angular = require('angular');
angular.module(require('../utils')).factory('logger', function () {});

Testing the application

We already had a suite of tests that we needed to ensure would continue working after we modularized the source. Originally, we used something similar to our dashboard, in which we manually maintained the list of included scripts. Yes, we needed to keep track of our list of scripts in two places.

We continued using Karma as our test runner and Istanbul as our code coverage tool, but we had to do some extra configuration to get the tests to run as before and generate meaningful coverage.

Back to the entry point

Our entry point requires our application and pulls in all of our test files. Since the application requires all of its dependencies (and those dependencies require their dependencies), we end up getting everything that we need. And, as with the pattern we used in our source modules, we used require.context to load up all of the test files using the pattern /\.spec\.js$/ (you might or might not have a pattern like this for your test files):

require('../../app/scripts/app');
var load = require.context('./', true, /\.spec\.js$/);
load.keys().forEach(load);

Updating the configuration for testing

In our Karma configuration, we then updated the list of files to include in the suite by specifying the path to the entry file that we created. We also defined webpack as a preprocessor for the suite of tests and included the common webpack configuration that we used for the development of the application:

var webpackConfig = require('../webpack.config');
module.exports = function (config) {
  return {
    // ... webpack related configuration ...
    webpack: webpackConfig,

    files: ['test/spec/suite.js'],

    preprocessors: {
      'test/spec/suite.js': ['webpack']
    }
    // ... end webpack related configuration ...
  }
}

For this code to work, you’ll need to have the karma-webpack plugin installed. To install it, run:

npm install --save-dev karma-webpack

If our tests were run now, things would probably work. Code coverage would definitely be broken, though. So add istanbul-instrumenter-loader:

npm install --save-dev istanbul-instrumenter-loader

And in the webpack configuration, add istanbul-instrumenter to the list of module postloaders:

var webpackConfig = require('../webpack.config');

// Use istanbul-instrumenter to make sure we're covering individual files
// instead of bundles
webpackConfig.module.postLoaders = webpackConfig.module.postLoaders || [];
webpackConfig.module.postLoaders.push({
  test: /\.js$/,
  // Exclude the things we don't need to report coverage on
  exclude: /(test|node_modules|bower_components)\//,
  loader: 'istanbul-instrumenter'
});

Additional considerations (dependency injection)

One of Angular’s shining features is dependency injection (DI). But after you modularize your scripts, DI loses some of its luster. With modularization, you can do everything you did with DI, and more. And DI comes with pitfalls:

  • You might be left searching around in your source code to locate where you declared myCoolFactory.
  • Suppose you inadvertently create two factories with the same name. The application can start acting oddly because all of the dependencies in the application are stored in a global hash, and one of the factories was overwritten by the other — not a fun situation to debug.
  • With DI you need to repeat yourself, or add a new build step, in case your code gets mangled as part of the minification process and those injected dependencies don’t work anymore.

It’s hard to move away from DI completely in favor of pure CommonJS or AMD dependencies. Furthermore, it arguably goes against the “Angular way,” and you could end up with a Frankenstein-like code base. But eschewing DI is something to consider if you want to take advantage of all the work you did to modularize your application.

Conclusion

The earlier in your project you get started with modularization, the better. If you have a legacy Angular application that you want to modularize, you’ll need to do some work, and retrofitting can consume extra cycles while you try to remember how files depend on one another. But when you’re done, you’ll have a much more manageable solution. If you’re starting from scratch, there’s no better time than now to start modularizing your code.