In the early days when modularity was introduced in JavaScript, there was no native support for running modules within the browser. Support for modular programming was being implemented in Node.js using the CommonJS blueprint and it was being adopted by those using JavaScript for building server-side applications.
It also had prospects for large web applications as developers could avoid namespace collisions and build more maintainable codebases by writing code in a more modular pattern. But there was still a challenge: modules couldn’t be used within web browsers, where JavaScript was usually executed.
To solve this problem, module bundlers such as webpack, Parcel, Rollup and also Google’s Closure Compiler were written to create optimized bundles of your code for your end-user’s browser to download and execute.
What Does It Mean To “Bundle” Your Code?
Bundling code refers to combining and optimizing multiple modules into one or more production-ready bundles. The bundle mentioned here can be understood better as the end product of the entire bundling process.
In this article, we will be focusing on webpack, a tool written by Tobias Koppers, which over time has grown to become a major tool within the JavaScript toolchain, often used in large and small projects.
Note: To benefit from this article, it’s a good idea to be familiar with JavaScript modules. You will also need Node installed on your local machine, so you can install and use webpack locally.
What Is webpack?
webpack is a highly extensible and configurable static module bundler for JavaScript applications. With its extensible nature, you can plug in external loaders and plugins to achieve your end goal.
As shown in the illustration below, webpack goes through your application from a root entry point, builds a dependency graph comprising of dependencies that act directly or indirectly on the root file and produces optimized bundles of the combined modules.
To understand how webpack works, we need to understand some terminology that it uses (check webpack Glossary. This terminology is often used in this article, and it’s also frequently referenced in webpack’s documentation.
- Chunk
A chunk refers to the code extracted from modules. This code will be stored in a chunk file. Chunks are commonly used when performing code-splitting with webpack. - Modules
Modules are broken-down parts of your application which you import to perform a specific task or function. Webpack supports modules created using the ES6, CommonJS and AMD syntax. - Assets
The term assets is frequently used within webpack and other bundlers in general. It refers to the static files bundled during the build process. These files could be anything from images to fonts or even video files. As you read further down the article, you will see how we use loaders to work with different asset types.
Recommended reading: Webpack – A Detailed Introduction
Once we’ve understood what webpack is and what terminology it uses, let’s see how they apply in putting together a configuration file for a demo project.
Note: You will also need webpack-cli
installed to use webpack on your machine. If not installed, you will be prompted from your terminal to install it.
webpack Configuration Files
Alternatively to using the webpack-cli from a terminal, you can use webpack in your project via a configuration file. But with the recent versions of webpack, we can use webpack in our project without a configuration file. We then use webpack
as a value of one of the commands in our package.json
file, without any flag. This way, webpack will assume your project’s entry point file lives in the src
directory. It will bundle the entry file and output it to the dist
directory.
An example is a sample package.json
file below. We use webpack to bundle the application without a configuration file:
{ "name" : "Smashing Magazine", "main": "index.js", "scripts": { "build" : "webpack" }, "dependencies" : { "webpack": "^5.24.1" }
}
When running it the build command in the file above, webpack will bundle the file in the src/index.js
directory and output it in a main.js
file in a dist
directory. webpack is, however, much more flexible than that. We can change the entry point, adjust the output point and refine many other default behaviors by editing a configuration file with the -- config
flag.
An example is the modified build command from the package.json
file above:
"build" : "webpack --config webpack.config.js"
Above, we added the --config
flag and specified a webpack.config.js
as the file having the new webpack configuration.
The webpack.config.js
file doesn’t exist yet though. So we need to create it in our application directory and paste the following code below into the file.
# webpack.config.js const path = require("path") module.exports = { entry : "./src/entry", output : { path: path.resolve(__dirname, "dist"), filename: "output.js" }
}
The file above still configures webpack to bundle your JavaScript file, but now we can define a custom entry and output file paths rather than the default path used by webpack.
A few things to note about a webpack configuration file:
- A webpack configuration file is a JavaScript file, written as a JavaScript CommonJS module.
- A webpack configuration file exports an object with several properties. Each of these properties is used as an option to configure webpack when bundling your code. An example is the
mode
option:mode
In configuration, this option is used to set theNODE_ENV
value during bundling. It can either have aproduction
ordevelopment
value. When not specified, it will default tonone
. It’s also important to note that webpack bundles your assets differently based on themode
value. For example, webpack automatically caches your bundles in development mode to optimize and reduce the bundle time. Refer to the mode section of the webpack documentation to see a changelog of the options automatically applied in each mode.
webpack Concepts
When configuring webpack either via the CLI or through a configuration file, there are four main concepts that are applied as options. The next section of this article focuses on these concepts and applies them when building the configuration for a demo web application.
It’s worth noting that the concepts explained below share some similarities with other module bundlers. For example, when using Rollup with a configuration file, you can define an input field to specify the entry point of the dependency graph, an output object configuring how and where the produced chunks are placed, and also a plugins object for adding external plugins.
Entry
The entry field in your configuration file contains the path to the file from where webpack starts building a dependency graph. From this entry file, webpack will proceed to other modules which depend directly or indirectly on the entry point.
Your configuration’s entry point can be a Single Entry type with a single file value, similar to the example below:
# webpack.configuration.js module.exports = { mode: "development", entry : "./src/entry" }
The entry point can also be a multi-main entry type having an array containing the path to several entry files, similar to the example below:
# webpack.configuration.js const webpack = require("webpack") module.exports = { mode: "development", entry: [ './src/entry', './src/entry2' ],
}
Output
Just as the name implies, a configuration’s output field is where the created bundle will live. This field comes in handy when you have several modules in place. Rather than using the name generated by webpack, you can specify your own filename.
# webpack.configuration.js const webpack = require("webpack");
const path = require("path"); module.exports = { mode: "development", entry: './src/entry', output: { filename: "webpack-output.js", path: path.resolve(__dirname, "dist"), }
}
Loaders
By default, webpack only understands JavaScript files within your application. However, webpack treats every file imported as a module as a dependency, and adds it to the dependency graph. To process static resources such as images, CSS files, JSON files or even your data stored in CSV, webpack uses loaders to “load” these files into the bundle.
Loaders are flexible enough to be used for a lot of things, from transpiling your ES code, to handling your application’s styles or even linting your code with ESLint.
There are three ways to use loaders within your application. One of them is through the inline method by directly importing it in the file. For example, to minimize image size, we can use the image-loader
loader in the file directly as shown below:
// main.js import ImageLoader from 'image-loader'
Another preferred option to use loaders is via your webpack configuration file. This way, you can do more with loaders, such as specifying the file types you want to apply the loaders to. To do this, we create a rules
array and specify the loaders in an object, each having a test field with a regex expression matching the assets we want to apply the loaders to.
For examples, with image-loader
imported directly in the previous example, we can use it in the webpack configuration file with the most basic options from the documentation. This will look like this:
# webpack.config.js const webpack = require("webpack")
const path = require("path")
const merge = require("webpack-merge") module.exports = { mode: "development", entry: './src/entry', output: { filename: "webpack-output.js", path: path.resolve(__dirname, "dist"), }, module: { rules: [ { test: /\.(jpe?g|png|gif|svg)$/i, use: [ 'img-loader' ] } ] }
}
Take a closer look at the test
field in the object that contains the image-loader
above. We can spot the regex expression that matches all image files: either jp(e)g
, png
, gif
and svg
format.
The last method of using Loaders is via the CLI with the --module-bind
flag.
The awesome-webpack readme contains an exhaustive list of loaders that you can use with webpack, each grouped into categories of operations that they perform. Below are just a few loaders that you might find handy in your application:
- Responsive-loader
You will find this loader very helpful when adding images to fit your responsive site or app. It creates multiple images of various sizes from a single image and returns asrcset
matching the images for use at appropriate display screen sizes. - Babel-loader
This is used for transpiling your JavaScript code from modern ECMA syntax to ES5. - GraphQL-Loader
If you are a GraphQL enthusiast, you’ll find this loader quite helpful as it loads your.graphql
files containing your GraphQL schema, queries, and mutations — along with the option to enable validation.
Plugins
The use of plugins allows webpack compiler to perform tasks on chunks produced from the bundled modules. Although webpack is not a task runner, with plugins, we can perform some custom actions which the loaders could not perform when the code was being bundled.
An example of a webpack plugin is the ProgressPlugin built-in to webpack. It provides a way to customize the progress which is printed out in the console during compilation.
# webpack.config.js const webpack = require("webpack")
const path = require("path")
const merge = require("webpack-merge") const config = { mode: "development", entry: './src/entry', output: { filename: "webpack-output.js", path: path.resolve(__dirname, "dist"), }, module: { rules: [ { test: /\.(jpe?g|png|gif|svg)$/i, use: [ 'img-loader' ] } ] }, plugins: [ new webpack.ProgressPlugin({ handler: (percentage, message ) => { console.info(percentage, message); }, }) ]
} module.exports = config
With the Progress plugin in the configuration above, we provided a handler function that will print out the compilation percentage and message to the console during the compilation process.
Below are a few plugins from the awesome-webpack readme which you will find handy in your webpack application.
- Offline-plugin
This plugin utilizes service workers first or the AppCache where available to provide an offline experience for webpack managed projects. - Purgecss-webpack-plugin
This plugin comes in handy when trying to optimize your webpack project as it removes unused CSS within your application during compilation.
At this point, we have our first webpack configuration for a relatively small application fully set up. Let’s further consider how we can do certain things with webpack in our application.
Handling Multiple Environments
In your application, you might need to configure webpack differently for either a development or production environment. For example, you might not want webpack to output minor warning logs each time a new deployment is made to your continuous integration pipeline in your production environment.
There are several ways to achieve that, as recommended by webpack and the community. One way is to convert your configuration file to export a function that returns an object. This way, current environment will be passed into the function by the webpack compiler as its first parameter, and other option as the second parameter.
This method of handling your webpack environment will come in handy if there are a few operations you’d like to perform differently based on the current environment. However, for larger applications with more complex configurations, you could end up with a configuration packed with plenty of conditional statements.
The code snippet below shows an example of how to handle a production
and development
environment in the same file using the functions
method.
// webpack.config.js module.exports = function (env, args) { return { mode : env.production ? 'production' : 'development', entry: './src/entry', output: { filename: "webpack-output.js", path: path.resolve(__dirname, "dist"), }, plugins: [ env.development && ( new webpack.ProgressPlugin({ handler: (percentage, message ) => { console.info(percentage, message); }, }) ) ] }
}
Going through the exported function in the code snippet above, you’ll see how the env
parameter passed into the function is being used with a ternary operator to switch values. It’s first used to set the webpack mode, then it’s also used to enable the ProgressPlugin only in development mode.
Another more elegant way to handle your production and development environment is to create different configuration files for the two environments. Once we’ve done that, we can use them with different commands in the package.json
scripts when bundling the application. Take a look at the snippet below:
{ "name" : "smashing-magazine", "main" : "index.js" "scripts" : { "bundle:dev" : "webpack --config webpack.dev.config.js", "bundle:prod" : "webpack --config webpack.prod.config.js" }, "dependencies" : { "webpack": "^5.24.1" }
}
In the package.json
above, we have two script commands, each using a different configuration file written to handle a specific environment when bundling the application’s assets. Now you can bundle your application using npm run bundle:dev
in development mode, or npm run bundle:prod
when creating a production-ready bundle.
Using the second approach, you avoid conditional statements introduced when returning your configuration object from a function. However, now you also have to maintain multiple configuration files.
Splitting Configuration File
At this point, our webpack configuration file is at 38 lines of code (LOC). This is quite fine for a demo application with a single loader and a single plugin.
For a larger application though, our webpack configuration file will definitely be much longer, having several loaders and plugins with their custom options each. To keep the configuration file clean and readable, we can split the configuration into smaller objects across multiple files then use the webpack-merge package to merge the configuration objects into one base file.
To apply it to our webpack project, we can split the single configuration file into three smaller files: one for loaders, one for plugins, and the last file as the base configuration file where we put the two other files together.
Create a webpack.plugin.config.js
file and paste the code below into it to use the plugins with additional options.
// webpack.plugin.config.js
const webpack = require('webpack') const plugin = [ new webpack.ProgressPlugin({ handler: (percentage, message ) => { console.info(percentage, message); }, })
] module.exports = plugin
Above, we have a single plugin which we extracted from the webpack.configuration.js
file.
Next, create a webpack.loader.config.js
file with the code below for the webpack loaders.
// webpack.loader.config.js const loader = { module: { rules: [ { test: /\.(jpe?g|png|gif|svg)$/i, use: [ 'img-loader' ] } ] }
}
In the code block above, we moved the webpack img-loader
into a separate file.
Finally, create a webpack.base.config.js
file where the base input and output configuration for the webpack application will be kept alongside the two created files above.
// webpack.base.config.js
const path = require("path")
const merge = require("webpack-merge") const plugins = require('./webpack.plugin.config')
const loaders = require('./webpack.loader.config') const config = merge(loaders, plugins, { mode: "development", entry: './src/entry', output: { filename: "webpack-output.js", path: path.resolve(__dirname, "dist"), }
}); module.exports = config
Taking a glance at the webpack file above, you can observe how compact it is in comparison to the original webpack.config.js
file. Now the three main parts of the configuration have been broken into smaller files and can be used individually.
Optimizing Large Builds
As you keep working on your application over a period of time, your application will definitely grow larger in features and size. As this happens, new files will be created, old files will be modified or refactored, and new external packages will be installed — all leading to an increase in the bundle size emitted by webpack.
By default, webpack automatically tries to optimize bundles on your behalf if your configuration mode is set to production
. For example, one technique that webpack applies by default (starting with webpack 4+) to optimize and reduce your bundle size is Tree-Shaking. Essentially, it’s an optimization technique used to remove unused code. At a simple level during bundling, the import and export statements are used to detect unused modules before removing them from the emitted bundles.
You can also manually optimize your application bundle by adding an optimization
object with certain fields into your configuration file. The optimization section of the webpack documentation contains a full list of fields you can use in the optimization
object to, well, optimize your application. Let’s consider one out of the 20 documented fields.
minimize
This boolean field is used to instruct webpack to minimize the bundle size. By default, webpack will try to achieve this using TerserPlugin, a code minification package shipped with webpack.
“Minification applies to minimizing your code by removing unnecessary data from the code which in turn reduces the code size produced after the process.”
We can also use other preferred minifiers by adding a minimizer
array field within the optimization
object. An example is the use of Uglifyjs-webpack-plugin below.
// webpack.config.js
const Uglify = require("uglifyjs-webpack-plugin") module.exports = { optimization { minimize : true, minimizer : [ new Uglify({ cache : true, test: /\.js(\?.*)?$/i, }) ] } }
Above, uglifyjs-webpack-plugin
is being used as a minifier with two quite important options. First, enabling cache
means that Uglify will only minify existing files when they are new changes, and the test
option specifies the specific file types we want to minify.
Note: The uglifyjs-webpack-plugin gives a comprehensive list of the options available for use when minifying your code with it.
A Little Optimization Demo
Let’s manually try to optimize a demo application by applying some fields in a larger project to see the difference. Although we won’t dive deep into optimizing the application, we’ll see the difference in bundle sizes between when running webpack in development
mode, versus when in production
mode.
For this demo, we’ll use a desktop application built with Electron that also uses React.js for its UI — all bundled together with webpack. Electron and React.js sound like a pretty heavy combination and might likely generate a bigger bundle.
Note: If you are learning about Electron for the first time, this article gives a good insight into what Electron is and how you can use it for building cross-platform desktop applications.
To try out the demo locally, clone the application from the GitHub repository and install the dependencies using the commands below.
# clone repository
git clone https://github.com/vickywane/webpack-react-demo.git # change directory
cd demo-electron-react-webpack # install dependencies
npm install
The desktop application is fairly simple with a single page styled using styled-components. When the desktop application is launched with the yarn start
command, the single page displays a list of images fetched from a CDN, as shown below.
Let’s create a development bundle of this application first without any manual optimization to analyze the final bundle size.
Running yarn build:dev
from a terminal in the project directory will create the development bundle. Plus, it will print out the following statistics to your terminal:
The command will show us the statistics of the entire compilation and the emitted bundles.
Take note of the mainRenderer.js
chunk is at 1.11 Mebibyte (approx 1.16 MB). The mainRenderer
is the entry point for the Electron application.
Next, let’s add uglifyjs-webpack-plugin as an installed plugin in the webpack.base.config.js
file for code minification.
// webpack.base.config.js
const Uglifyjs = require("uglifyjs-webpack-plugin") module.exports = { plugins : [ new Uglifyjs({ cache : true }) ]
}
Lastly, let’s run bundle the application with webpack in production
mode. Running yarn build:prod
command from your terminal will output the data below to your terminal.
Take a note of the mainRenderer
chunk this time. It has dropped to a whopping 182 Kibibytes (approximately 186 KB), and that’s more than 80% of the mainRenderer
chunk size emitted previously!
Let’s further visualize the emitted bundles using the webpack-bundler-analyzer. Install the plugin using the yarn add webpack-bundle-analyzer
command and modify the webpack.base.config.js
file to contain the code below which adds the plugin.
// webpack.base.config.js
const Uglifyjs = require("uglifyjs-webpack-plugin");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer"); .BundleAnalyzerPlugin; const config = { plugins: [ new Uglifyjs({ cache : true }), new BundleAnalyzerPlugin(), ]
}; module.exports = config;
Run yarn build:prod
from your terminal for the application to be re-bundled. By default, webpack-bundle-analyzer will start an HTTP server that serves the visualized overview of the bundles in your browser.
From the image above, we can see a visual representation of the emitted bundle and file sizes within the bundle. In the visual, we can observe that in the folder node_modules
, the biggest file is the react-dom.production.min.js
, followed by stylis.min.js
.
Using the file sizes visualized by the analyzer, we’ll have a better idea of what installed package is contributing the major portion of the bundle. We can then look for ways to optimize it or replace it with a lighter package.
Note: The webpack-analyzer-plugin documentation lists other means available for displaying the analysis created from your emitted bundles.
One of the strengths of webpack has been the large community of developers behind it and this has been of great use to developers trying webpack out for the first time. Just like this article, there are several articles, guides and resources with the documentation that serves as a great guide when using webpack.
For example, Build Performance guide from webpack’s blog contains tips on optimizing your webpack builds and Slack’s case study (although a bit old) explains how webpack was optimized at Slack.
Several community resources explain parts of webpack’s documentation, providing you with sample demo projects to show how features of webpack are being used. An example is an article on Webpack 5 Module Federation which explains how webpack’s new Module Federation feature is used in a React application.
Summary
After seven years of its existence, webpack has truly proved itself to be an important part of the JavaScript toolchain used by a large number of projects. This article only gives a glimpse into the things one can achieve with webpack’s flexible and extensible nature.
The next time you need to choose a module bundler for your application, hopefully you will better understand some core concepts of Webpack, the problem it solves, and also the steps of setting up your configuration files.
Further Reading on SmashingMag:
(ks, vf, yk, il)