Skip to main content

Initial setup

These days webpack has a pretty decent documentation, yet configuring webpack based application from scratch comes with its challenges. This guide is intended to walk the reader through all the steps of setting up modern webpack based configuration. The intention is to break down the process into major milestones that could be used for future extensions by themselves or coupled together as one basic setup.

npm package manager is used for the reset of the guide. There is nothing special about the choice and yarn could be used instead

Let's start installing everything needed for the initial setup. An assumption is made that a default installation folder has been selected and initialized with a default package.json file.

npm i -y

Dependencies

Webpack

npm i webpack webpack-cli webpack-dev-server -D --save-exact

The core of the webpack setup consist of 3 packages:

  • webpack - the bundler itself
  • webpack-cli - webpack command line interface (different options exist)
  • webpack-dev-server - development server

TypeScript

Webpack configuration is based on a JavaScrip file. This is the point where things start to get complicated. Without knowing different config options and the theory behind them, it takes some patience to settle different parts down. For that reason, we'll be creating our configuration in TypeScript. This will give us both the IDE support and the config file documentation at hand. Following packages are in use:

  • typescript - TypeScript compiler itself (including cli)
  • ts-node - TypeScript execution and REPL provider (so we can transpile and execute ts files in one go)

A bunch of TypeScript type definition packages to support smooth compilation and documentation experience:

  • @types/node - type definitions for NodeJs
  • @types/webpack- webpack type definitions
  • @types/webpack-dev-server - webpack-dev-server type definitions
npm i @types/node @types/webpack @types/webpack-dev-server -D --save-exact

HTML

Out of the box, webpack understands only a basic dialect of JavaScript. That is a good start, but for the working example an index.html file generation mechanism is needed. The way to extend webpack capabilities is via plugins. Let's install HTML related plugins and corresponding type definition files:

  • html-webpack-plugin - generates index.html file from the given template
  • html-webpack-harddisk-plugin - makes sure index.html file is written to disk (more on this later)
  • @types/html-webpack-plugin - html-webpack-plugin type definitions
npm i html-webpack-plugin html-webpack-harddisk-plugin @types/html-webpack-plugin -D --save-exact

Utilities

To finish the installation, let's add some utility packages.

  • clean-webpack-plugin - webpack plugin for cleaning up generated assets
  • cross-env - cross platform environment variable setup
npm i clean-webpack-plugin cross-env -D --save-exact

One line setup

npm i @types/clean-webpack-plugin @types/html-webpack-plugin \
@types/node @types/webpack @types/webpack-dev-server \
clean-webpack-plugin cross-env html-webpack-harddisk-plugin \
html-webpack-plugin ts-node typescript webpack \
webpack-cli webpack-dev-server -D --save-exact

TypeScript

We are going to keep all the webpack related configurations in a separate folder. Following commands create webpack folder, TypeScript config file with default options, and move it to the newly created folder.

mkdir webpack
npx tsc --init --module commonjs --target es5 --strict true --esModuleInterop true --noEmit true
mv tsconfig.json webpack/tsconfig.json

Typescript options:

  • --module - we are executing webpack config under Node, hence commonjs as a module option
  • --target - target JavaScript dialect as Ecma 5. This is mostly to make sure webpack runs smoothly. We are not going to generate JavaScript anyway, so the specific target does not represent a major difference.
  • --strict - strict type checking is enabled
  • --esModuleInterop - enable default imports (interoperability between commonjs and es6 modules)
  • --noEmit - do not generate output files

Environments

Out of the box, webpack supports several operational modes, development, production, none. These modes will loosely correspond to a target build environments final setup is going to need. However, having explicitly set environments in the code helps splitting settings for different plugins and presets across different technologies.

environments.js

// @ts-check

const values = Object.freeze({
Prod: 'production',
Development: 'development'
})

/**
* @param {string=} env
*/
module.exports = (env) => {

env = env || process.env.NODE_ENV

return {
get isProduction() {
return env === values.Prod
},

get isDevelopment() {
return env === values.Development
},

get current() {
return env
}
}
}

This time we went with the JavaScript option. That is because not every single part of tech in our stack understands TypeScript (as we'll see later).

Configuration file

Parts

We are going to have two configuration files, one per each environment specified in a previous section. To remove duplications across different webpack config files, we are going to put common parts into parts.ts inside the webpack folder. Webpack type definitions will help us here making sure we create everything according the specification.

parts.ts

import path from 'path';
import webpack, { Entry, Output, Node, Plugin } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import HtmlWebpackHarddiskPlugin from 'html-webpack-harddisk-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';

export const distFolder = () => path.resolve(__dirname, '../dist')

export const getParts = () => ({
context: path.join(__dirname, '../src', 'app'),

entry: {
main: './index',
} as Entry,

output: {
path: path.resolve(distFolder(), 'js'),
filename: '[name].bundle.js',
publicPath: '/js'
} as Output,

node: {
fs: 'empty'
} as Node,

plugins: [
new webpack.EnvironmentPlugin(['NODE_ENV']),
new HtmlWebpackPlugin({
chunksSortMode: 'dependency',
filename: '../index.html',
alwaysWriteToDisk: true
}),
new HtmlWebpackHarddiskPlugin(),
new CleanWebpackPlugin({ verbose: true })
] as Plugin[]
})

Here we define a context of the project. It represents a base/root folder path. Everything else will be resolved relatively.

Next, we define the application main entry point. The key main will be used by the webpack to create JavaScript output bundle file.

Next is the output section. It defines how and where assets are layed out:

  • path - represents physical folder path for the assets (JavaScript file in this case)
  • filename - defines a chunk file name inside the assets folder. [name].bundle.js means take a chunk name from the entry section and substitute the [name] with it. So the final output JavaScript file name will be main.bundle.js
  • publicPath - defines how final html page will be including JavaScript asset entries. In our case it will be using /js prefix and assets file name right after. Example: /js/main.bundle.js.

node section defines NodeJs substitutes for the modules not relevant to the web execution engine. For now we have specified only fs in case some third party library uses it.

plugins section is used to extend webpack capabilities. In our case, we use:

  • EnvironmentPlugin to map NODE_ENV key passed from the environment context into the execution environment.
  • HtmlWebpackPlugin to create index.html file automatically with all the assets hooked up.
  • HtmlWebpackHarddiskPlugin to make sure index.html file is written to disk even in dev mode, so webpack dev server can serve it properly.
  • CleanWebpackPlugin to clean up all the assets generated during previous build execution.

Development

webpack.config.dev.ts

import { Configuration } from 'webpack';
import Environments from './environments.js'
import { getParts, distFolder } from './parts'

const environments = Environments()
console.log(`Running webpack config for environment: ${environments.current}`);

const parts = getParts()

const config: Configuration = {

context: parts.context,

mode: 'development',

entry: parts.entry,

devtool: 'cheap-module-eval-source-map',

output: parts.output,

node: parts.node,

plugins: parts.plugins,

devServer: {
contentBase: distFolder(),
overlay: true,
hot: true,
open: true
}
}

export default config

The major parts here are:

  • mode - defines webpack operation mode.
  • devtool - defines source mapping generation approach. cheap-module-eval-source-map gives a middle ground between the speed and information provided. But you can experiment with the different options for your set up.
  • devServer - defines webpack dev server configuration section.
  • contentBase - defines the folder path to server all the content from (including static assets)
  • overlay - if there is a compilation error, show full overlay
  • hot - enable hot module reloading
  • open - opens default browser on start

Production

webpack.config.ts

import webpack, { Configuration } from 'webpack';
import Environments from './environments.js'
import { getParts } from './parts'

const environments = Environments()
console.log(`Running webpack config for environment: ${environments.current}`);

const parts = getParts()

const config: Configuration = {

context: parts.context,

mode: 'production',

entry: parts.entry,

output: parts.output,

node: parts.node,

plugins: [
...parts.plugins,

new webpack.SourceMapDevToolPlugin({
filename: '[name].js.map',
lineToLine: true
})
],

optimization: {
minimize: true,
removeAvailableModules: true
}
}

export default config

This time, the mode is production and the devtool option is substituted with the SourceMapDevToolPlugin.

  • filename - file name pattern for source map files.
  • lineToLine - matched modules are mapped one to one to sources (speeds up mapping generation)

optimization section defines two additional parameters:

  • minimize - minimizes JavaScript assets using uglify-js (default minifier)
  • removeAvailableModules - do not include modules present in parent chunks

Scripts

Add following scripts to scripts section under the package.json file:

build:dev - builds output assets using development environment settings. Suitable for inspecting built assets in a dev mode.

"build:dev": "cross-env TS_NODE_PROJECT=\"./webpack/tsconfig.json\" NODE_ENV=development webpack --config ./webpack/webpack.config.dev.ts"

start:dev - starts the dev server and builds in memory assets. Mostly used for dev work loads

"start:dev": "cross-env TS_NODE_PROJECT=\"./webpack/tsconfig.json\" NODE_ENV=development webpack-dev-server --config ./webpack/webpack.config.dev.ts"

build:prod - build output assets using production environment settings. Mostly used for production deployments.

"build:prod": "cross-env TS_NODE_PROJECT=\"./webpack/tsconfig.json\" NODE_ENV=production webpack --config ./webpack/webpack.config.ts",

ts-node is the execution engine behind webpack configuration using TypeScript. When we execute a script command, at first, the config file will be picked up by the ts-node and transpiled to JavaScript. But for this to happen we need to explain to ts-node where tsconfig.json file is. Hence we are executing script commands with the additional twist of specifying environment variable for the ts-node project (TS_NODE_PROJECT).

Sources

Finally, after finishing with all the plumbing, we create the application source code. Create src folder under the root of the project. Create app folder under src folder. Add index.js file to the app folder:

mkdir src
cd src
mkdir app
cd app
touch index.js

if (module.hot) {
module.hot.accept()
}

const func = () => {
console.log('test')
}

func()

module.hot.accept makes sure that we accept and update the module during hot replacement. The method is provided by the HotModuleReplacementPlugin that ships with the webpack installation.

Try running the app in a dev mode by executing start:dev command script. If everything is set up correctly, you should see test written to the browser console.

Final version

Reference implementation