Decrease Front-end Size

One of the first things to do when you’re optimizing an application is to make it as small as possible. Here’s how to do this with webpack.

Enable minification

Minification is when you compress the code by removing extra spaces, shortening variable names and so on. Like this:

// Original code
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}

Webpack supports two ways to minify the code: the UglifyJS plugin and loader-specific options. They should be used simultaneously.

The UglifyJS plugin works on the level of the bundle – it compresses the bundle after compilation. Here’s how it works:

  1. You write code like this:
    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. Webpack compiles it into approximately the following:
    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
      console.log('Rendered!');
    }
    
  3. The UglifyJS plugin minifies it into approximately the following:
    // minified bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    

The plugin comes bundled with webpack. To enable it, add it to the plugins section of the config:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

The second way is loader-specific options (what a loader is). With loader options, you can compress things that the UglifyJS plugin can’t minify. For example, when you import a CSS file with css-loader, the file is compiled into a string:

/* comments.css */
.comment {
  color: black;
}

// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

UglifyJS can’t compress this code because it’s a string. To minify the file content, we need to configure the loader to do this:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};

Further reading

Specify NODE_ENV=production

Another way to decrease the front-end size is to set the NODE_ENV environmental variable in your code to the value production.

Libraries read the NODE_ENV variable to detect in which mode they should work – in the development or the production one. Some libraries behave differently based on this variable. For example, when NODE_ENV is not set to production, Vue.js does additional checks and prints warnings:

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// …

React works similarly – it loads a development build that includes the warnings:

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
  componentClass.getDefaultProps.isReactClassApproved,
  'getDefaultProps is only used on classic React.createClass ' +
  'definitions. Use a static property named `defaultProps` instead.'
);
// …

Such checks and warnings are usually unnecessary in production, but they remain in the code and increase the library size. Configure webpack to remove them with the DefinePlugin:

 // webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"',
    }),
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

The DefinePlugin replaces all occurrences of a specified variable with a specific value. With the config from above:

  1. The DefinePlugin will replace all occurrences of process.env.NODE_ENV with "production":
     // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
  1. And then the UglifyJsPlugin will remove all such if branches – because "production" !== 'production' is always false, and the plugin understands that the code inside these branches will never execute:
    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    

Further reading

Use ES modules

The next way to decrease the front-end size is to use ES modules.

When you use ES modules, webpack becomes able to do tree-shaking. Tree-shaking is when a bundler traverses the whole dependency tree, checks what dependencies are used, and removes unused ones. So, if you use the ES module syntax, webpack can eliminate the unused code:

  1. You write a file with multiple exports, but the app uses only one of them:
    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack understands that commentRestEndpoint is not used and doesn’t generate a separate export point in the bundle:
    // bundle.js (part that corresponds to comments.js)
    (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      const render = () => { return 'Rendered!'; };
      /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
      const commentRestEndpoint = '/rest/comments';
      /* unused harmony export commentRestEndpoint */
    })
    
  3. The UglifyJsPlugin removes the unused variable:
    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    

This works even with libraries if they are written with ES modules.

Further reading

Optimize images

Images account for more than a half of the page size. While they are not as critical as JavaScript (e.g., they don’t block rendering), they still eat a large part of the bandwidth. Use url-loader, svg-url-loader and image-webpack-loader to optimize them in webpack.

url-loader inlines small static files into the app. Without configuration, it takes a passed file, puts it next to the compiled bundle and returns an url of that file. However, if we specify the limit option, it will encode files smaller than this limit as a Base64 data url and return this url. This inlines the image into the JavaScript code and saves an HTTP request:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loader works just like url-loader – except that it encodes files with the URL encoding instead of the Base64 one. This is useful for SVG images – because SVG files are just a plain text, this encoding is more size-effective:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: 'svg-url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
          // Remove the quotes from the url
          // (they’re unnecessary in most cases)
          noquotes: true,
        },
      },
    ],
  },
};

image-webpack-loader compresses images that go through it. It supports JPG, PNG, GIF and SVG images, so we’re going to use it for all these types.

This loader doesn’t embed images into the app, so it must work in pair with url-loader and svg-url-loader. To avoid copy-pasting it into both rules (one for JPG/PNG/GIF images, and another one for SVG ones), we’ll include this loader as a separate rule with enforce: 'pre':

 // webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre',
      },
    ],
  },
};

The default settings of the loader are already good to go – but if you want to configure it further, see the plugin options. To choose what options to specify, check out Addy Osmani’s excellent guide on image optimization.

Further reading

Optimize dependencies

More than a half of average JavaScript size comes from dependencies, and a part of that size might be just unnecessary.

For example, Lodash (as of v4.17.4) adds 72 KB of minified code to the bundle. But if you use only, like, 20 of its methods, then approximately 65 KB of minified code does just nothing.

Another example is Moment.js. Its 2.19.1 version takes 223 KB of minified code, which is huge – the average size of JavaScript on a page was 452 KB in October 2017. However, 170 KB of that size is localization files. If you don’t use Moment.js with multiple languages, these files will bloat the bundle without a purpose.

All these dependencies can be easily optimized. We’ve collected optimization approaches in a GitHub repo – check it out!

Enable module concatenation for ES modules (aka scope hoisting)

When you are building a bundle, webpack is wrapping each module into a function:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();

}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }

})

In the past, this was required to isolate CommonJS/AMD modules from each other. However, this added a size and performance overhead for each module.

Webpack 2 introduced support for ES modules which, unlike CommonJS and AMD modules, can be bundled without wrapping each with a function. And webpack 3 made such bundling possible – with ModuleConcatenationPlugin. Here’s what this plugin does:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
  function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();

})

See the difference? In the plain bundle, module 0 was requiring render from module 1. With ModuleConcatenationPlugin, require is simply replaced with required function, and module 1 is removed. The bundle has fewer modules – and less module overhead!

To enable this behavior, add ModuleConcatenationPlugin into the list of plugins:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin(),
  ],
};

Further reading

Use externals if you have both webpack and non-webpack code

You might have a large project where some code is compiled with webpack, and some code is not. Like a video hosting site, where the player widget might be built with webpack, and the surrounding page might be not:

A screenshot of a video hosting site
(A completely random video hosting site)

If both pieces of code have common dependencies, you can share them to avoid downloading their code multiple times. This is done with the webpack’s externals option – it replaces modules with variables or other external imports.

If dependencies are available in window

If your non-webpack code relies on dependencies that are available as variables in window, alias dependency names to variable names:

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },
};

With this config, webpack won’t bundle react and react-dom packages. Instead, they will be replaced with something like this:

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

If dependencies are loaded as AMD packages

If your non-webpack code doesn’t expose dependencies into window, things are more complicated. However, you can still avoid loading the same code twice if the non-webpack code consumes these dependencies as AMD packages.

To do this, compile the webpack code as an AMD bundle and alias modules to library URLs:

// webpack.config.js
module.exports = {
  output: { libraryTarget: 'amd' },

  externals: {
    'react': { amd: '/libraries/react.min.js' },
    'react-dom': { amd: '/libraries/react-dom.min.js' },
  },
};

Webpack will wrap the bundle into define() and make it depend on these URLs:

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () { … });

If non-webpack code uses the same URLs to load its dependencies, then these files will be loaded only once – additional requests will use the loader cache.

Further reading

Summing up

  • Minimize your code with the UglifyJsPlugin and loader options
  • Remove the development-only code with the DefinePlugin
  • Use ES modules to enable tree shaking
  • Compress images
  • Apply dependency-specific optimizations
  • Enable module concatenation
  • Use externals if this makes sense for you