Bundling files with Webpack 5

Your application might consist of hundreds of files. To run those files in production you need to optimize them. There are many ways to optimize your source code for production. Webpack is helping you here.

yarn add --dev webpack webpack-cli

Webpack is a built tool that “bundles” code for development or production, depending on its configured mode. All configuration is usually done within webpack.config.js at project root level. You configure one or more JS files as an entry point, depending on whether you have a Single- or Multi Page application. Then you configure an output path. Webpack will create a dependency graph, meaning it traverses all your imported JS modules and keeps track of the references. You can also configure loaders to make Webpack pre-process/transform certain files such as CSS files, TypeScript files, image files and many more. Webpack plugins allow for further optimization such as asset management and injection of environment variables. In the end Webpack will produce one or more optimized bundle files.

Configuration

All configuration should be done in webpack.config.js located in your project root folder. That file exports the Webpack configuration as a Node.js CommonJS module which means that you can require(...) other modules and use control flow expressions, constants, variables and functions. You should avoid creating large configuration files, but instead split them up into multiple ones.

# loading webpack with configuration
webpack --config webpack.config.js
// or simply
webpack

Note that webpack will not alter any code other than import and export statements. If you are using other ES2015 features, make sure to use a transpiler such as Babel.

Entry

A single entry as you would use for Single Page Applications:

module.exports = {
  entry: './path/to/my/entry/file.js'
};

or multiple (named) entries for Multi Page applications:

module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js'
  }
};

Output

Single chunk

This configuration would output a single bundle.js file into the dist directory

module.exports = {
  output: {
    filename: 'bundle.js',
  }
};

Multiple chunks

If your configuration creates more than a single “chunk” (as with multiple entry points or when using plugins like CommonsChunkPlugin) you can ensure that each file has a unique name:

module.exports = {
  entry: {
    app: './src/app.js',
    search: './src/search.js'
  },
  output: {
    filename: '[name].js',
    path: __dirname + '/dist'
  }
};

// writes to disk: ./dist/app.js, ./dist/search.js

Hashed chunks

You should create bundle files that contain their hash values in their names, so that they are unique. That is important to prevent caching issues, for example showing old content despite new files – only because they have the same name.

module.exports = {
  //...
  output: {
    path: '/home/proj/cdn/assets/[hash]',
    publicPath: 'https://cdn.example.com/assets/[hash]/'
  }
};

Dynamic runtime variables

In cases where the eventual publicPath of output files isn't known at compile time, it can be left blank and set dynamically at runtime 
__webpack_public_path__ = myRuntimePublicPath;

// rest of your application entry

Loaders

As already mentioned in the intro, loaders allow you to pre-process files as you import or “load” them. The recommended way is to specify them in the webpack.config.js file, though it is also possible inline or via CLI. Loaders are evaluated/executed from right to left or from bottom to top regarding the rules array configuration below. Loaders can be synchronous or asynchronous, run in NodeJS environment, can be configured via an options object and emit additional arbitrary files. Also plugins can enhance their functionality.

TypeScript loader

Configure Webpack, so you can convert TypeScript to JavaScript:

yarn add --dev ts-loader
module.exports = {
  module: {
    rules: [
      { test: /\.ts$/, use: 'ts-loader' }
    ]
  }
};

Loading CSS

yarn add -dev style-loader css-loader
  const path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
    module: {
      rules: [
        {
          test: /\.css$/,
          use: [
            'style-loader',
            'css-loader',
          ],
        },
      ],
    },
  };

Loading images

yarn add --dev file-loader
  const path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
    module: {
      rules: [
        {
          test: /\.(png|svg|jpg|gif)$/,
          use: [
            'file-loader',
          ],
        },
      ],
    },
  };

Loading fonts

yarn add --dev file-loader
const path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
    module: {
      rules: [
        {
          test: /\.(woff|woff2|eot|ttf|otf)$/,
          use: [
            'file-loader',
          ],
        },
      ],
    },
  };

Now when decalring your fonts the url(...) directive will be picked up by webpack.

Plugins

That is an example on how a Plugin looks like:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, compilation => {
      console.log('The webpack build process is starting!!!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

And here is an example on how to use/configure a plugin:

const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm
const webpack = require('webpack'); //to access built-in plugins
const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    filename: 'my-first-webpack.bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader'
      }
    ]
  },
  plugins: [
    new webpack.ProgressPlugin(),
    new HtmlWebpackPlugin({template: './src/index.html'})
  ]
};

Cleaning the dist folder

yarn add --dev clean-webpack-plugin
 const { CleanWebpackPlugin } = require('clean-webpack-plugin');

  module.exports = {
    plugins: [
     new CleanWebpackPlugin(),
    ],
  };

Webpack DevServer

yarn add --dev webpack-dev-server webpack-dev-middleware express

Add devServer and output publicPath to webpack.config.js:

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      app: './src/index.js',
      print: './src/print.js',
    },
    devtool: 'inline-source-map',
    devServer: {
      contentBase: './dist',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
    },
  };

The publicPath will be used within our server script as well in order to make sure files are served correctly on http://localhost:3000

We configure devtool in order to make it easier to track down errors and warnings, JavaScript offers source maps, which map your compiled code back to your original source code

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

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

// Tell express to use the webpack-dev-middleware and use the webpack.config.js
// configuration file as a base.
app.use(webpackDevMiddleware(compiler, {
  publicPath: config.output.publicPath,
}));

// Serve the files on port 3000.
app.listen(3000, function () {
  console.log('Example app listening on port 3000!\n');
});
"scripts": {
      "watch": "webpack --watch",
      "start": "webpack-dev-server --open",
      "build": "webpack"
    },

Run npm start. If you now change any of the source files and save them, the web server will automatically reload after the code has been compiled.

This tells webpack-dev-server to serve the files from the dist directory on localhost:8080. webpack-dev-server doesn’t write any output files after compiling. Instead, it keeps bundle files in memory and serves them as if they were real files mounted at the server’s root path

Hot module replacement

HMR allows all kinds of modules to be updated at runtime without the need for a full refresh. The following only works if you are NOT using Dev Server with the NodeJS API.

    devServer: {
      contentBase: './dist',
      hot: true,
    },

When using Webpack Dev Server with the NodeJS API, don’t put the dev server options on the webpack configuration object. Instead, pass them as a second parameter upon creation. For example:

const webpackDevServer = require('webpack-dev-server');
const webpack = require('webpack');

const config = require('./webpack.config.js');
const options = {
  contentBase: './dist',
  hot: true,
  host: 'localhost',
};

webpackDevServer.addDevServerEntrypoints(config, options);
const compiler = webpack(config);
const server = new webpackDevServer(compiler, options);

server.listen(5000, 'localhost', () => {
  console.log('dev server listening on port 5000');
});

And if you’re using webpack-dev-middleware you have to install and configure webpack-hot-middleware:

yarn add --dev webpack-hot-middleware
plugins: [
    new webpack.HotModuleReplacementPlugin(),
]

Next, add 'webpack-hot-middleware/client' into the entry array.

var webpack = require('webpack');
var webpackConfig = require('./webpack.config');
var compiler = webpack(webpackConfig);

app.use(require("webpack-dev-middleware")(compiler, {
    noInfo: true, publicPath: webpackConfig.output.publicPath
}));
app.use(require("webpack-hot-middleware")(compiler));

Adjusting your code to use HMR

 if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('Accepting the updated printMe module!');
      document.body.removeChild(element);
      element = component(); // Re-render the "component" to update the click handler
      document.body.appendChild(element);
    })
  }

Targets

Because JavaScript can be written for both server and browser, webpack offers multiple deployment targets that you can set in your webpack configuration.

Target node will compile for usage in a Node.js-like environment (uses Node.js require to load chunks and not touch any built in modules like fs or path).

module.exports = {
  target: 'node'
};

Code splitting

You can split your code into various bundles which can then be loaded on demand or in parallel. There are three general approaches to code splitting available: Entry Points, SplitChunksPlugin and Dynamic Imports.

Entry points

The easiest way to split code is by defining an additional entry.

const path = require('path');

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js',
    another: './src/another-module.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

The big downside to this is that the two resulting bundles might have duplicated code. We can prevent duplication using dependOn:

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      index: { import: './src/index.js', dependOn: 'shared' },
      another: { import: './src/another-module.js', dependOn: 'shared' },
      shared: 'lodash',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
  };

SplitChunksPlugin

A better way to prevent duplication is by using SplitChunksPlugin (formerly CommonsChunkPlugin).

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      index: './src/index.js',
      another: './src/another-module.js',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
    optimization: {
      splitChunks: {
        chunks: 'all',
      },
    },
  };

We should now see that the duplicate dependency got removed from our index.bundle.js and another.bundle.js and extracted to its own file.

More useful plugins are mini-css-extract-plugin for splitting CSS out from the main application and bundle-loader to split code and lazy load the resulting bundles (or promise-loader to use Promises).

Dynamic imports

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      index: './src/index.js',
    },
    output: {
      filename: '[name].bundle.js',
     chunkFilename: '[name].bundle.js',
      publicPath: 'dist/',
      path: path.resolve(__dirname, 'dist'),
    },
  };

chunkFilename determines the name of non-entry chunk files. Now, instead of statically importing lodash, we’ll use dynamic importing to separate a chunk:

// src/index.js
function getComponent() {
  return import(/* webpackChunkName: "lodash" */ 'lodash').then(({ default: _ }) => {
    const element = document.createElement('div');
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
    return element;

   }).catch(error => 'An error occurred while loading the component');
  }

 getComponent().then(component => {
   document.body.appendChild(component);
 })

Here /* webpackChunkName: “lodash” */ is not just any comment, it is a so called magic comment that gets interpreted – in this case as the file name for the generated chunk.

As import() returns a promise, it can be used with async functions. However, this requires using a pre-processor like Babel and the Syntax Dynamic Import Babel Plugin.

Variabels in dynamic imports

// imagine we had a method to get language from cookies or other storage
const language = detectVisitorLanguage();
import(`./locale/${language}.json`).then(module => {
  // do something with the translations
});

import(`./locale/${language}.json`) will cause every .json file in the ./locale directory to be bundled into the new chunk. At run time, when the variable language has been computed, any file like english.json or german.json will be available for consumption.

Prefetching/Preloading modules

The following will result in <link rel="prefetch" href="login-modal-chunk.js"> being appended in the head of the page, which will instruct the browser to prefetch in idle time the login-modal-chunk.js file.

import(/* webpackPrefetch: true */ 'LoginModal');

Bundle Analysis

You can find a bunch of tools in the Webpack documentation that lets you analyze the output to check where modules have ended up or how much space they use.

Environment variables

webpack --env.NODE_ENV=local --env.production --progress
const path = require('path');

module.exports = env => {
  // Use env.<YOUR VARIABLE> here:
  console.log('NODE_ENV: ', env.NODE_ENV); // 'local'
  console.log('Production: ', env.production); // true

  return {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
  };
};

Troubleshooting Webpack

I kept getting messages like this, for example when using expressJS:

Critical dependencies:  the request of a dependency is an expression

The solution is to install and configure webpack-node-externals which creates an externals function that ignores node_modules when bundling in Webpack.

var nodeExternals = require('webpack-node-externals');
...
module.exports = {
    ...
    target: 'node', // in order to ignore built-in modules like path, fs, etc.
    externals: [nodeExternals()], // in order to ignore all modules in node_modules folder
    ...
};

Why is that feature not implemented in Webpack right away? Why does it need to install and configure an external package? I don’t know.

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.