Guide to webpack configuration

Published on

Using JavaScript (or TypeScript) in 2021 often involves many (many many many!) layers of build tools between what you write and what gets deployed.

One of the most common tools to build your bundles is webpack.

Here is an introduction to webpack, including the key features you will need to understand in order to set up and configure webpack.

Table of Contents

Webpack overview

  • Webpack lets you bundle assets.
  • You give it an 'entry point' (or multiple entry points)
  • It builds up a dependency graph, starting from the entry point. This means it finds every file included (such as import Something from 'something', const SomethingElse = require('something-else')). These can be JS, TypeScript, CSS, images or other assets.
  • It will then process them, and output them in 1 or more bundles. (Often in your ./dist/ directory).
  • In a very basic app you might output index.js which combines all of your JS, and index.css which combines all your CSS.
  • Config is done via webpack.config.js
    • (or more rarely in package.json)

Key topics to understand:

Entry or Entry Point

  • This is your index.js (e.g. in ./src/index.js). It will probably include other files (such as import Something from './something.js')
  • You can have multiple entry points. Maybe one for your main app, and also a separate one for your admin side.

There are a few ways to set this up. The most simple is with a simple key:value.

The example below (entry: '...') is actually setting it with the name main.

webpack.config.js
// Note: this is shorthand for the next ('named entry')
// example with `main` as the name...
module.exports = {
  entry: './path/to/my/entry/file.js',
};

You can use named entries (this example is equivalent to the previous example)

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

Example of using named entries with two different files:

webpack.config.js
module.exports = {
  entry: {
    app: './src/app.js',
    adminApp: './src/adminApp.js',
  },
};

Multiple entries (multi-main entry). This is when you have multiple entries, outputting the same bundle.

webpack.config.js
module.exports = {
  entry: ['./src/file_1.js', './src/file_2.js'],
  output: {
    filename: 'bundle.js',
  },
};

You can also do things such as requiring the output of one entry and using it in another entry (look for dependOn in their docs).

Output

The output is what webpack should do with your asset(s) once it has bundled it.

The default is to put your JS in ./dist/main.js and any other assets (images, css etc) in ./dist

You might not like the default output name. You can set this up with the output config:

webpack.config.js
const path = require('path')

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-first-webpack.bundle.js',
  },
}

That might be useful if you have just one... but if you have multiple entries you will probably want to use properties such as the entry name...

webpack.config.js
module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    filename: '[name].[contenthash].bundle.js',
  },
}

There are multiple substitutions that you can use. The previous example used:

  • [name] which is the entry name (such as app or adminApp from a previous example). Default name is main.
  • [contenthash] The hash of the chunk, including only elements of this content type

Output path vs publicPath

The output config says where to put the bundled files (often in ./dist).

But sometimes your code and scripts need to reference them for production. You can use publicPath (often with filename) to tell Webpack what url it can be found at (once deployed)

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

module.exports = {
  output: {
    path: path.resolve(__dirname, 'public/assets'),
    publicPath: 'https://cdn.example.com/assets/',
  },
};

Loaders

Webpack treats all imports (import ... from ... or require()) as modules.

But it doesn't know how to import every kind of module, and this is where loaders come in.

  • Webpack by default supports only JS and JSON. But it is likely you will want to include files such as the following from your entry files:

  • SVG

  • other images

  • CSS

  • Typescript

  • and more

You can set up a loader to tell webpack what to do when it sees an import of that file type.

You add loaders in your webpack config. You have to tell it what files to run a loader on.

  • the test - usually a regex which if it matches your filename, it will run the loader
  • the use which tells webpack what loader to use. This is often the name of a npm package.
webpack.config.js
const path = require('path');

module.exports = {
 output: {
   filename: 'my-first-webpack.bundle.js',
 },
 module: {
   rules: [
       {
          test: /\.txt$/, // does the filename end in .txt?
          use: 'raw-loader', // use the 'raw-loader' loader
       }
   ],
 },
};

If you have const txtData = require('./mydata.txt'), when webpack sees that it will test the mydata.txt path against the /\.txt$\ regex (it will match!). As it matches, it will use the raw-loader loader to process that import.

How to import CSS in webpack with the CSS loader

First you need to add the css loader:

yarn add -D css-loader

then update your webpack config:

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

Configuring loaders

You can add options to loaders like this:

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

Ordering of loaders

  • You can chain multiple loaders
  • They are run right to left
  • loaders are separated by !, or an array

examples:

webpack.config.js
module: {
    rules: [
        {
            test: /\.html$/,
            use: ['html-loader', 'ejs-loader'] // with array chaining
        }
    ]
},

Or

webpack.config.js
module: {
    rules: [
        {
            test: /\.html$/,
            use: 'html-loader!ejs-loader' // with ! chaining
        }
    ]
},

In both examples above the ejs-loader will run first, then it will pass its results to the html-loader loader to run on that.

Inline loaders

You can also define what loader to use in your imports, such as:

some-file.js
import Styles from 'style-loader!css-loader?modules!./styles.css';

(this uses the chaining via !)

Other important things to note about loaders:

  • Loaders can be synchronous or asynchronous.
  • Loaders can do anything you can do in Node
  • Loaders can emit additional arbitrary files.

Plugins

At first it can be a bit confusing when it comes to plugins and loaders.

But it can be summarised like this:

  • Loaders are used to tell webpack how to import a file type (e.g. .css). It just helps import a single file at a time.
  • Plugins can work on a bundle/chunk, and can modify the entire bundle/chunk. These plugins often work near the end of the bundling process (after loaders).

Plugins are set up in your webpack config by importing it (via require(), then passing it to the plugins config):

Here is an example of html-webpack-plugin (which must be included via npm/yarn):

webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin'); 

module.exports = {
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
};

You will tend to use the new keyword with plugins, as you may instantiate it multiple times within your webpack config.

Some examples of plugins that you might see:

  • DefinePlugin (official webpack plugin) to pass down env vars to your JS link
  • ProgressPlugin provides a way to customize how progress is reported during a compilation link
  • Terser to minify your JS npm)
  • optimize-css-assets-webpack-plugin to optimize / minimize CSS assets npm
  • babel-loader to transpile your JS with Babel - useful if you want to write modern JS but then use babel to transpile to JS that works with older browsers npm
  • imagemin-webpack-plugin to compress your image assets github

Mode

You will make use of mode to set your app to either development, production or none.

It will default to production.

It will set process.env.NODE_ENV to the value you provide.

This will use the webpack.DefinePlugin to set these values.

Using the mode in your webpack config

You can access it by turning your config into functions:

webpack.config.js
var config = {
  entry: './app.js',
  //...
};

module.exports = (env, argv) => {
  if (argv.mode === 'development') {
    config.devtool = 'source-map';
  }

  if (argv.mode === 'production') {
    //...
  }

  return config;
};

webpack-dev-server & the webpack CLI

Something which seems to often be glossed over in tutorials is the difference between generating your assets for deployment and local dev with webpack-dev-server.

Hot Module Replacement (HMR) exchanges, adds, or removes modules while an application is running, without a full reload.

What this means is that you can make changes in your code, and your browser will reload certain parts of your JS (without reloading the page), which greatly improves developer experience.

There are a few ways to set this up, but the most common is to use webpack-dev-server. This is basically the same as building your static assets (like you probably will for production deploys) - but it spins up its own server and handles the module updates.

You would use the webpack-dev-server just for local development.

Most of the configuration used for the CLI tool & webpack-dev-server are exactly the same. There are a few which are only for one (like the hot option which is only for webpack-dev-server).

Sometimes hot module replacement won't work (you lose state/data etc) - in those cases you can set webpack-dev-server to reload the entire page (like hitting cmd + r / f5).