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 = [];

//

commonsChunkList.push(

new webpack.ContextReplacementPlugin(

/graphql-language-service-interface[\\/]dist$/,

new RegExp('^\\./.*\\.js$')

)

); // 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

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.