Webpack 4 in production: How to make your life easier

Recently, we went through a Webpack upgrade saga in one of the bigger production apps that I’ve been working on for the past couple of years. The project was relying on Webpack 1.14.0 and with Webpack 4 out, now it was a good time to show some love to the project and simplify things.

The goal was specifically to decrease the bundle size and utilize a better code splitting mechanism in the project.

Background

The subject app was created back in early 2015 as a Rails 4 project. Back in that time, Webpacker gem didn’t exist and Rails lacked built-in support for webpack. As a result, the app was architected in a unique way to enable this hybrid Rails/Javascript coexistence. We were aiming to support React in Rails.

This is how this Rails project is structured:

.
├── app
│ ├── assets
│ ├── browser
│ ├── controllers
│ ├── helpers
│ ├── jobs
│ ├── mailers
│ ├── middleware
│ ├── models
│ ├── redshift
│ ├── sanitizers
│ ├── serializers
│ ├── services
│ ├── sms
│ ├── uploaders
│ └── views
├── babel-plugins
├── bin
├── config
├── db
├── grunt
├── lib
├── log
├── node_modules
├── public
├── spec
├── tmp
└── ...

All React components are put in app/browser. We configured Webpack to read that folder and find all the entries to the Javascript app.

Besides webpack package, we were also using grunt-webpack which basically integrates webpack into grunt build process.

Before Upgrade

Before starting the upgrade, our Webpack config file was a big scary file:

var fs = require('fs');
var path = require('path');
var _ = require('lodash');
var dirs = require('./dirs');
var webpack = require('webpack');
var webpackGenEntries = require('./webpack_gen_entries');
// Lookup paths for module name resolution
var MODULE_PATHS = [
dirs.LIB_SRC,
dirs.REACT_GENERIC,
'./node_modules',
];
var baseDependencies = [
'babel-polyfill',
];
var entries = webpackGenEntries({
vendor: [
'babel-polyfill',
'react',
'react-dom',
'react-relay',
'reflux',
'lodash',
],
});
var namespaces = entries.__namespaces__;
var subentries = entries.__subentries__;
delete entries['__namespaces__'];
delete entries['__subentries__'];
delete entries['__needs_react__'];
var devChunkNames = [];
if (namespaces['__alone__']) {
devChunkNames = devChunkNames.concat(namespaces['__alone__']);
}
var nonDevChunkNames = [];
var commonsChunkList = [];
// we have added the next line because of this:
// https://github.com/graphql/graphql-language-service/issues/128
commonsChunkList.push(
new webpack.ContextReplacementPlugin(
/graphql-language-service-interface[\\/]dist$/,
new RegExp('^\\./.*\\.js$')
)
);
Object.keys(entries).forEach(function(entryName) {
if (devChunkNames.indexOf(entryName) === -1 &&
subentries.indexOf(entryName) === -1) {
nonDevChunkNames.push(entryName);
}
});
commonsChunkList.unshift(
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
chunks: nonDevChunkNames,
filename: 'vendor_app.js',
minChunks: 2,
})
);
var babelLoader = {
test: /\.js$/,
exclude: [
/node_modules/,
],
loader: 'babel-loader',
};
var sharedConfig = {
resolve: {modulesDirectories: MODULE_PATHS},
context: process.cwd(),
node: {__filename: true},
entry: entries,
output: {
path: dirs.DEST,
filename: "[name].js",
},
module: {
loaders: [
babelLoader,
{
test: /\.json$/,
loader: 'json-loader'
}
]
},
externals: [{
xmlhttprequest: '{XMLHttpRequest:XMLHttpRequest}'
}],
storeStatsTo: "webpackStats",
};
var productionConfig = _.assign(_.cloneDeep(sharedConfig), {
stats: {
colors: false,
hash: true,
timings: true,
assets: true,
chunks: true,
chunkModules: true,
modules: true,
children: true,
},
progress: true,
failOnError: true, // don't report error to grunt if webpack find errors
watch: false,
keepalive: false,
plugins: [
new webpack.DefinePlugin({
__DEV__: false,
__PROD__: true,
'process.env':{
'NODE_ENV': JSON.stringify('production')
},
}),
].concat(commonsChunkList),
});
var developmentConfig = _.assign(_.cloneDeep(sharedConfig), {
devtool: 'cheap-module-eval-source-map',
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
},
progress: true,
failOnError: false, // don't report error to grunt if webpack find errors
watch: true,
keepalive: true,
plugins: [
new webpack.DefinePlugin({
__DEV__: true,
__PROD__: false,
}),
].concat(commonsChunkList),
});
module.exports = {
development: developmentConfig,
production: productionConfig,
};

Don’t worry about it; you can skip through it. Here, we first defined appropriate dev and prod configs: they are called developmentConfig and productionConfig respectively. As a second step, grunt-webpack would call webpack with the appropriate config file based on the value of NODE_ENV environment. If that’s set to production, productionConfig will be used and otherwise we would default to developmentConfig.

Upgrade Process

We took a gradual step in the upgrade process. First we upgraded to Webpack 2. After we tested everything and made sure that things are working as expected, the next step was to bump the version to 3. We repeated the same process and at the end we landed on Webpack version 4.3.0.

Upgrade from webpack 1 to 2 was the most challenging part; the process is documented well in this post.

Upgrading version from 2 to 3 and 4 was pretty much seamless: no breaking changes. Bumping the version in package.json did the trick for us.

Webpack 4: Production vs. Development

One of the selling points of Webpack 4 is that it’s a zero configuration bundler; by default it doesn’t need a configuration file. This is great for smaller to medium sized projects, although for a big production level app, some finer grained control is appreciated.

For our project, we created two different config files: one for development and the other for production.

  • development config file is used to define the config items that you care about in development mode
  • production config file is used to define UglifyJSPlugin, splitChunks (we will discuss it later) and so on.

We created three different files to hold our webpack configs:

webpack.common.js: this file held the common settings between dev and prod environments
webpack.dev.js: dev-specific settings would go here
webpack.prod.js: prod-specific settings would go here

Here is how webpack.common.js looks

As mentioned, this file defines what’s common in both development and production modes. Entries to the app is a common setting for both environments.

What you really care about is line 22 to 48. The rest is specific to our app.

Our app is a hybrid one. Some of our views are only relying on Rails templates, some of the others are solely using React components and the rest use a combination of both. Line 10–18 helps us to find all entry points to our Javascript code. webpackGenEntries is a module created by us which basically returns all entrypoints to the other components in our project.

For us, our entries array looks like this:

dashboard: './app/browser/dashboard/index.js',
discover_schools: './app/browser/discover_schools/index.js',
edit_metadata_wrapper: './app/browser/edit_metadata_wrapper/index.js',
graph_iql: './app/browser/graph_iql/index.js',
high_school_search: './app/browser/high_school_search/index.js',
my_story: './app/browser/my_story/index.js',
navbar_app: './app/browser/navbar_app/index.js',
org_feed: './app/browser/org_feed/index.js',
reset_password_form: './app/browser/reset_password_form/index.js',
sign_in_form: './app/browser/sign_in_form/index.js',
sign_up_form: './app/browser/sign_up_form/index.js',
smart_banner: './app/browser/smart_banner/index.js',
v3_profile: './app/browser/v3_profile/index.js'

These are basically all react entrypoints in our app. Each of these components is embedded in a Rails view page.

Let’s go through other important items in module.exports object:

resolve (line 23): This configures how modules are found. For example, when calling import "lodash", the resolve options can change where webpack goes to look for "lodash". In our case, we are pointing webpack to look under MODULE_PATHS:

const MODULE_PATHS = [
dirs.LIB_SRC,
dirs.REACT_GENERIC,
'./node_modules',
];

Basically we are saying alway look at these directories to know where to load dependencies from.

context (line 24): This is the base directory, for resolving entry points and loaders.

entry (line 26): As discussed, this is the point or points to enter the application. At this point the application starts running. According to webpack docs, if an array is passed all items will be executed.

output.path (line 28): This specifies the output directory as an absolute path.

output.filename (line 29): The name of each output bundle in the output directory are defined using this option.

module.rules (line 37– 47): Here we define an array of Rules which are matched to requests when modules are created. These rules can modify how the module is created. They can apply loaders to the module, or modify the parser. 
In our case, we are telling webpack to use babel-loader for all JS files that are not located in node_modules directory. This enables us to use new ES6 syntax in our JS files.

This concludes our webpack.common.js settings. To find more information about the configs we could pass here, Look at webpack docs.


Now let’s take a look at development config file (webpack.dev.js)

Webpack Configuration for Development

This one is pretty minimal:

In line 4, we import our already defined webpack.common.js. This gives us access to default settings.

Later on line 7, we use webpack-merge to merge common settings with development specific ones.

Later we set devtool: ‘inline-source-map’ for our dev environment. This gives us strong source mapping which could be helpful in debugging. We are also able to override settings provided in webpack.common.js file. For our use case, we don’t need to do that.

In line 8, we set mode: ‘development’. This is a new option provided by Webpack 4. The allowed value is either development or production. Here we set it to development to enable defaults for this environment.

The defaults for development provides the following:

  • Better tooling for in-browser debugging.
  • Fast incremental compilation for a fast development cycle.
  • Better error messages at runtime.

The last config file is webpack.prod.js.

Webpack Configuration for Production

This is where things gets interesting. Let’s see what’s happening here:

Like the previous file, we use webpack-merge to get access to the options defined in webpack.common.js.

Line 8, the mode is set to production. As mentioned, doing that enables all types of optimizations by default; That Includes, scope hoisting, tree-shaking and minification.

Line 9 to 18 defines stats option. It lets us precisely control what bundle information gets displayed. This is just for statistics purposes and you can drop it if you want. If you are looking for more information about stats options, take a look here.

Line 19 to 44 defines our optimization options. We are specifically defining three different options: minimizier, runtimeChunk and splitChunks. Let’s look at each:

minimizer option: This lets us specify what minimizer library we would like to use. UglifyJSPlugin is the most popular one; although, our options are not limited to that. We could also choose either of BabelMinifyWebpackPlugin or ClosureCompilerPlugin. In our case we have also passed sourceMap: true; this allows us to have source maps enabled in production, which is going to be helpful for debugging on prod.


splitChunks option: webpack 4 removes the good old CommonsChunkPlugin in favor of two new options (optimization.splitChunks and optimization.runtimeChunk). By default Webpack 4 does some optimizations that should work great for most users. But in our case, we wanted a finer grained control over it so we defined it as:

splitChunks: {
cacheGroups: {
default: false,
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor_app',
chunks: 'all',
minChunks: 2
}
}
}

This optimization assigns modules to cache groups. Basically, we are assigning all modules from node_modules that are duplicated in at least 2 chunks to a cache group called vendor_app. This helps us to extract out common chunks into a specific file (vendor_app.js). In production, that file will download once and will be cached; so later the cached version could be used: Download once, use everywhere!

Examples of how the new splitChunks technique works could be found here.


runtimeChunk option: optimization.runtimeChunk: true adds an extra chunk to each entrypoint containing only the runtime. Here we are not interested in that, so we simply turned it off.

Voila!

This concludes our webpack configuration.

The last step is to define some build scripts in package.json:

"scripts": {
...
"webpack-prod": "webpack -p --progress --config=config/webpack.prod.js --mode production",
"webpack-dev": "webpack -p --progress --config=config/webpack.dev.js --mode development",
...
},

This gives us access to two commands that we can run in development and production modes respectively:

  1. npm run webpack-dev
  2. npm run webpack-prod

As the last thing, let’s use webpack-bundle-analyzer to visualize our bundle content as an interactive treemap.

For development mode, our bundle looks like this:

Development Bundle Treemap

As you see there are a lot of duplication there. For example react-relay, relay-runtime, react-dom are repeated in each component. On the other hand, the size of bundle is 23 MB (Wow!). If you remember, we didn’t put any optimization settings in our webpack.dev.js, so this makes sense.

Now let’s look at the treemap generated for production mode based on optimization configs we specified earlier.

Production Bundle Treemap

As you see, a new file named vendor_app.js is added. All the modules that are shared at least between two other chunks are extracted into this file. So no more repetition of react-dom, react-relay, react-runtime and … . Also the overall bundle size is decreased to 800Kb. (Awesome!)

Conclusion

The zero configuration mindset placed into webpack 4 is a great step forward. The convention over configuration principle helps us to bootstrap a project swiftly and a lot of times is good enough for our needs. At the same time having a high degree of granularity in configuration is also needed for specific projects: we all love magic but it would be even better if we could understand and configure that magic.

Structuring dev/prod webpack configurations in the way advocated in this article, helps us to achieve a modular, adaptable and easy-to-change system.

When it comes to optimization, always measure first and then optimize. Also, always know that optimization is an open-ended game, you could spend one month on an optimization problem and make it slightly better but there must be a compromise; you must be able to justify the costs of doing so.

Resources

  1. https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
  2. https://www.valentinog.com/blog/webpack-4-tutorial/
  3. https://webpack.js.org/guides/production/
  4. https://auth0.com/blog/webpack-4-release-what-is-new/
  5. https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693