Optimizing your JavaScript Bundle | DefinePlugin Webpack

Monday, July 27, 2020

In this blog post, we are going to talk about webpack's DefinePlugin and how we can use it to optimize our JS bundle by conditionally loading modules when required.

What is DefinePlugin?

DefinePlugin is provided out of the box by Webpack. As per the documentation,

The DefinePlugin allows you to create global constants which can be configured at compile time.

In our webpack config --

new webpack.DefinePlugin({
  PRODUCTION: JSON.stringify(true),
});

Here we define a constant which would be available in the global scope of our bundle --

if (PRODUCTION) {
  console.log("Production");
}

Webpack will replace the PRODUCTION constant with the value provided in the config (here it is true). Every key provided in the DefinePlugin will be available as a constant in the bundle and values must be converted to string or webpack will stringify them for you. You can read more about the rules of identifiers here.

Problem Usecase

Most of the time, we set up our build process and ship a bundle to the client. We can do all sorts of fancy things like code-splitting, tree shaking, uglification and much more. However, we might ship features that aren't required or might be experimental. In such cases, we are pushing more code than required and unnecessarily increasing our bundle size. Here, DefinePlugin really shines and can help us avoid such circumstances.

For demo purposes, let's consider a use-case. Suppose, our system offers the following features

  1. Reporting
  2. Payment
  3. A/B Testing
  4. Click Tracking
  5. Experimental Feature 1
  6. Experimental Feature 2

However, not all our clients need all the features. Different clients might require different feature sets depending upon their needs, such as

  1. Client A might require Reporting and Payment features.
  2. Client B might require Reporting, A/B Testing, Click Tracking.
  3. Client C might want to try Experimental Features.
  4. Client D might require all features.

Hence, we can't ship all the features to all the clients. Doing so, would not be the optimal solution. Let's see how DefinePlugin can help us in solving this problem.

Code Time

You can find code for this blog post here.

We will set up the following directory structure

|-- dist
  |-- bundle.js
|-- src
  |-- configs
    |-- config-1.json
    |-- config-2.json
    |-- config-3.json
    ..
  |-- index.js
|-- main.js

Let's briefly look at what some of the file/folders means

  1. main.js

    • This file will contain all the code which would be required for generating our bundle.
  2. src

    • This folder will contain all the client-side code we need to ship.
  3. src/index.js

    • This file will be the entry point to our bundle.
  4. configs

    • This folder will contain all the config files which will dictate what goes into our bundle and what not.

Create a config-1.json which would allow us to include all the modules in our bundle. Different config files will dictate different bundles depending upon which features are enabled or disabled.

cd src/configs
touch config-1.json

Put the following code in the file

{
  "REPORTING": true,
  "PAYMENT": true,
  "A_B_TESTING": true,
  "CLICK_TRACING": true,
  "EXPERIMENTAL_FEATURE_1": true,
  "EXPERIMENTAL_FEATURE_2": true
}

Let's start working on our bundle generation. We are going to run webpack as part of a node service which would be a little different from traditionally creating webpack.config.js and running the webpack command from CLI.

Open the main.js and paste the following code:

const Promise = require('bluebird');
const path = require('path');
const fs = Promise.promisifyAll(require('fs'));
const webpack = require('webpack');

const CONFIG_PATH = './src/configs/config-1.json';

function init() {
  return fs
    .readFileAsync(CONFIG_PATH, { encoding: 'utf8' })
    .then(content => {
      ...
    });
}
init();

In the above code snippet, we are setting up all the required dependencies and reading the config file. This config file can be generated via any means as reading from a DB or some pre-processing mechanism that generates this config.

Now, let's look at our webpack config

  ...
  .then(content => {
      return new Promise((resolve, reject) => {
        const config = JSON.parse(content);

        if (!config || !Object.keys(config).length) return reject('Empty Config Found');

        const compiler = webpack({
          mode: 'production',
          entry: path.join(__dirname, 'src', 'index.js'),
          output: {
            path: path.join(__dirname, 'dist'),
            filename: 'bundle.js'
          },
          module: {
            rules: [
              {
                test: /.jsx?$/,
                loader: 'babel-loader',
                exclude: /node_modules/
              }
            ]
          },
          plugins: [new webpack.DefinePlugin({ ...config })]
        });
        new webpack.ProgressPlugin().apply(compiler);
        compiler.run(err => {
          return err ? reject(err) : resolve();
        });
      });
  })
  ...

In the above code snippet,

  1. We are creating a compiler instance bypassing the standard config to the webpack function.
  2. We are defining global constants as discussed earlier by using the DefinePlugin and passing the config read using file interface ('/configs/config-1.json').
  3. We are applying ProgressPlugin to our webpack instance to get a clear picture of what's happening and then calling the run method to start bundle creation.
  4. Run method takes a callback that has two parameters error and stats. We are only concerned with the error parameter for now.
  5. If any error occurs while creating the bundle then we will reject else resolve the promise.

We are here using the Node.js API provided by webpack. It is useful in our scenario as it provides us with more granular control over the build pipeline. You can read more about the Webpack's Node Interface here.

Now, I am going to add dummy modules so that we can run our build pipeline and compare the results. You can check out the github repo for the code.

Conditional Module Loading

Open the src/index.js and paste the following code

var reporting;
var payment;
var abTesting;
var clickTracking;
var experimentalFeature1;
var experimentalFeature2;

if (REPORTING) {
  reporting = require("./reporting.js");
}
if (PAYMENT) {
  payment = require("./payment.js");
}
if (A_B_TESTING) {
  abTesting = require("./abTesting.js");
}
if (CLICK_TRACING) {
  clickTracking = require("./clickTracking.js");
}
if (EXPERIMENTAL_FEATURE_1) {
  experimentalFeature1 = require("./experimental-1.js");
}
if (EXPERIMENTAL_FEATURE_2) {
  experimentalFeature2 = require("./experimental-2.js");
}

In the above code snippet, variables like REPORTING, PAYMENT, CLICK_TRACKING would be available to us in our bundle at compile time. Webpack will replace them with the values provided via DefinePlugin. Using these values, we are going to conditionally load our modules.

After adding dummy modules, let us build our bundle(s) and compare the results!

abTesting.js    267 KB
clickTracking.js  107 KB
experimental-1.js 54 KB
experimental-2.js 54 KB
payment.js      214 KB
reporting.js    161 KB
  1. Config 1 -- All modules enabled
{
  "REPORTING": true,
  "PAYMENT": true,
  "A_B_TESTING": true,
  "CLICK_TRACING": true,
  "EXPERIMENTAL_FEATURE_1": true,
  "EXPERIMENTAL_FEATURE_2": true
}

bundle.js     910 KB
  1. Config 2 -- Reporting, Payment and Experimental Feature 1 enabled
{
  "REPORTING": true,
  "PAYMENT": true,
  "A_B_TESTING": false,
  "CLICK_TRACING": false,
  "EXPERIMENTAL_FEATURE_1": false,
  "EXPERIMENTAL_FEATURE_2": true
}

bundle.js 457 KB
Reduction in size - 49.78%
  1. Config 3 -- A/B testing, Click Tracking, Experimental Feature 1 and Experimental Feature 2 enabled
{
  "REPORTING": false,
  "PAYMENT": false,
  "A_B_TESTING": true,
  "CLICK_TRACING": true,
  "EXPERIMENTAL_FEATURE_1": true,
  "EXPERIMENTAL_FEATURE_2": true
}

bundle.js 514 KB
Reduction in size - 43.52%

So, after comparing these results now we can see how DefinePlugin can help us in optimizing our JS bundle and provide more granular control over what goes into the bundle and what not.

You can find code for this blog post here.