February 03, 2018
For many web developers like myself, using the newest cutting edge technologies, frameworks and libraries is one of the things that makes this field most exciting. But the reality is the most jobs are not starting new greenfield applications, but instead maintaining and updating existing codebases. With these legacy codebases, adopting new technologies is more difficult and requires more thought to ensure as little disruption to ongoing feature development. Most of the time, rewrites are out of the question and the only way to adopt new technology is to incrementally migrate.
Along these lines, my team recently went through the long overdue process of migrating the bundling our AngularJS application from using Gulp to Webpack. I’d like to share the process we went through, some of the pitfalls, and what worked well.
For almost two years, I have been on a team working on an existing AngularJS application. In that time, we’ve managed to slowly improve the codebase, such as migrating from controllers to components and moving state management from UI router to Redux, all while continously shipping new features and bug fixes. One thing that has long been on my todo list is to switch to a module bundler instead of using Gulp tasks to concatenate files. Our existing build process pre-dates myself, and much of it comes from a boilerplate that was used to generate the initial project. While the goal was to switch to a module bundler, there were some constraints to ensure stability of the projects.
First, I wanted this change to be transparent to our existing process and not impact developer workflows. Our developers could use gulp
and that would start the development build, watch for changes and start a dev server. Our CI server could call gulp build
and output production assets to a specific folder. These two tasks needed to stay the same, both from input and output.
Second, we needed to leave as much of the code base was left untouched as possible. This was needed to avoid massive merge conflicts and risk of lost work since changes were continously being merged to our main development branch. This made a full conversion to either CommonJS or ES modules in source code not feasible.
Lastly, we wanted to do this migration incrementally. The first step was to migrate the JS, with the CSS and other tasks to still be generated by existing Gulp tasks.
In looking at module bundlers, Webpack became a pretty obvious choice. While it is not always the easist to work with, it’s popularity, documentation and large number of plugins and loaders made it stand out.
The first step was to set up our initial Webpack config. It ended up looking like:
const webpack = require("webpack");
module.exports = function(env = {}) {
const plugins = [];
const isProd = !!env.prod;
return {
entry: {
app: "./client/index.js"
// other entries
},
output: {
filename: "[name].js",
path: path.resolve("dist"),
publicPath: "our-public-path"
},
devtool: isProd ? "source-map" : "eval-source-map",
module: {
rules: [
{
test: /\.js$/
use: 'babel-loader',
exclude: /node_modules/
}
]
}
};
};
Our webpack.config.js
file ended up exporting a function that created the config object. This us allows us to pass options either via command like webpack --env.prod
or via JS (like from a Gulp task) webpack({ prod: true })
.
The new index.js
file was created to be the new entry point, designed to pull in everything we need for our app, including our dependecies and app modules. It look like:
import $ from "jquery";
import _ from "lodash";
// other global deps
import angular from "angular";
// other angular deps
// import app modules
import firstModule from "./first/index";
import secondModule from "./second/index";
import thirdModule from "./third/index";
// shim window namespace so existing app code relying on globals works
window.$ = $;
window._ = _;
const dependencies = [
// angular dependencies imported
];
const appModule = angular.module("my-app", dependencies);
require("./templates");
export default appModule;
Let’s go through what is going on here.
window
with different globals we want available through the rest of our app.Before we go any further, we need to generate our html output. To do this, we’ll use the HTML Webpack Plugin, which will generate our html page and inject the app bundle script tag. Our existing Gulp build used an html tempate, so we also needed the flexibility to customize our output. This plugin supports templates and we choose to use Handlebars for our templates. We simply changed the name of our template to index.hbs
, updated the syntax, and added a few changes to our webpack config.
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = function(env = {}) {
// existing logic
plugins.push(
new HtmlWebpackPlugin({
template: "client/index.hbs",
chunks: ["app"],
filename: "index.html",
inject: "body",
foo: "bar" // availble in our template via {{htmlWebpackPlugin.options.foo}}
// other options we want to expose to our template
})
);
return {
// rest of config
module: {
rules: [
// existing babel stuff
{
test: /\.hbs$/,
use: "handlebars-loader"
}
]
}
};
};
One issue we run into is that AngularJS is using jqLite instead of jQuery. This is because it is referencing window.$
in the AngularJS source, which is not defined since jQuery isn’t defined globally at that point. To fix this, we need to shim window.$
, which we can do via the Provide Plugin. We update our plugins as so:
module.exports = function(env = {}) {
// existing logic
plugins.push(
new webpack.ProvidePlugin({
"window.$": "jquery",
"window.jQuery": "jquery",
$: "jquery",
"window.$": "jquery"
})
// other plugins
);
// define config
};
For more info on this, see the Shimming Docs.
So far, all we have done is set up a boilerplate for a generic application. Next, we actually need to get our existing app code into our app. Since our app was already broken into a folder structure based around our AngularJS modules, we decided to use this as points to pull in the app code module by module. For each AngularJS module, we defined a new index.js
file in the root of the module directory (i.e. first/index.js
) which we used to pull in all the code for that module. Not only does this help keep our dependency order for our app modules, but it also sets us up nicely to convert our files to ES modules more incrementally.
The real question was how to do this without change our existing app code. We could manually add import statements for each file in the module, but that would get tedious and hard to maintain. This is where Webpack’s require.context
is really useful. This allows us to import all JS files within each directory without manually listing them all.
In the end, each module directory’s index file looked like:
import "./first.module";
function importAll(r) {
r.keys().forEach(r);
}
importAll(
// including subdirectories, find all *.js files except those matching *.module.js or *.spec.js
require.context("./", true, /^(?!.*\.module\.js$)^(?!.*\.spec\.js$).*\.js$/)
);
export default angular.module("first");
This does the following:
We can repeat this process for each of our app modules to pull in all our app JS.
The next piece of the app we need to fold into our bundle is our angular templates. The existing set up relies on [gulp-angular-templatecache] to bundle the templates for production builds as part of our JS bundle. Instead of relying on this, we can actually take advantage again of require.context
to pull in html template files for our app. We ended up with a file templates.js
which basically has the following:
function importTemplates(name) {
const ctx = require.context(`./${name}/`, true, /.*\.html$/);
angular.module(name).run([
"$templateCache",
function($templateCache) {
ctx.keys().forEach(key => {
$templateCache.put(key.replace("./", `${name}/`), ctx(key));
});
}
]);
}
//import templates by module
importTemplates("first");
importTemplates("second");
The following is the result:
./myComponent/myComponent.html
in first module is stored as first/myComponent/myComponent.html
, and assign the html content to that in the $templateCache
To make this work, we need to add html-loader to the rules section of our config.
{
test: /\.html$/,
use: "html-loader"
}
Now that we have the app code working, we need to ensure our our tests work. Our test setup uses Jasmine and Karma. Luckily, integrating Webpack is fairly trivial using karma-webpack. Here is what our karma.conf.js
file looks like to make it work:
const webpackConfig = require("./webpack.conf");
const webpackMerge = require("webpack-merge");
const webpackTestConfig = webpackMerge(webpackConfig({ test: true }), {
entry: "./client/specs.js"
});
module.exports = function(config) {
config.set({
files: ["client/specs.js"],
preprocessors: {
"client/specs.js": ["webpack"]
},
plugins: [
// ...
"karma-webpack"
],
webpack: webpackTestConfig,
webpackServer: {
noInfo: true
}
// ...
});
};
Here we’re using webpack-merge to use our app’s Webpack config and override the entry to point to our test entry point. Our specs.js
file is a single entry for all our specs that imports our app, helpers and specs.
// import our entire app
import "./index";
// test helpers
import "angular-mocks";
// ... import any other helpers or test dependencies
const testContext = require(".", true, /\.spec\.js$/);
testContext.keys().forEach(testContext);
The setup for this is pretty straight forward:
One last change we need to make is that this throws errors when we use the angular-mocks module
helper, so we need to change to use the full angular.mocks.module
.
Now that we have our JS set up to build with Webpack, we need to be able to run it via gulp. While there are libraries to integrate, we don’t really need one since we aren’t going to use the resulting stream. Instead, we’ll just create a function that invokes webpack. The result looks something like:
const gulp = require("gulp");
const webpack = require("webpack");
const webpackMerge = require("webpack-merge");
const webpackConfig = require("./webpack.config");
gulp.task("bundle", function() {
const config = webpackMerge(webpackConfig(), { watch: true });
return new Promise(resolve => {
webpack(config, resolve);
});
});
gulp.task("bundle:prod", function() {
const config = webpackConfig();
return new Promise(resolve => {
webpack(config, resolve);
});
});
Now all we need to do is update our existing gulp tasks to ensure this task is called as part of our dev and prod build chains respectively.
With the change to Webpack, we were able to delete a lot of gulp tasks and greatly simplify the workflow. The best part is we were able to make the change without having to make drastic changes to our code base, allowing us to make those changes incrementally while still shipping features. Hope this helps anyone else looking to migrate to webpack.
Written by Greg Babiars who builds things for the web. You can follow me on Twitter.