Hola folks!
Here's a documentation of my explorations with setting up Webpack. I checked out Webpack for the first time when I just needed to handle some SVG assets for a react project. But the more I read, the more I realized how beautiful and useful it is. I'm really pumped up to share my take-aways with y'all. Hope this helps other FE devs who want to get started.
As per Webpack's official doc,
Webpack is a static module bundler for modern JavaScript applications
But what does that mean? And how is it even useful?
Here's what I'll cover in this post.
- The what's and why's of Webpack
- Advantages of using it over traditional react-scripts of CRA (create-react-app)
- Setting up Webpack
- Loaders and Plugins
- Optimizing
01 The what's and why's of Webpack
Webpack is a bundler which manages the resources and assets of our project (like a CSS/SASS file, an image, or fonts) at compile time. It does so by making a dependency graph to refer to, for every node it visits while processing. That is how it makes sure that code that needs to load first, loads first.
Imagine you have a project where multiple javascript files depend on each other, like this very simple one here.
In calculateBattleIndex.js
function calcBattleIndex(hero) {
return (hero.strength * 2 + hero.defence * 3) / 10;
}
In calculatePower.js
function calcPower(hero) {
return hero.money / 100 + calcBattleIndex(hero);
}
In index.js
var batman = {
money: 100,
strength: 70,
defence: 92,
}
var superman = {
money: 50,
strength: 99,
defence: 80,
}
calcPower(batman);
calcPower(superman);
As you can see, the caclPower
function is dependent on calcBattleIndex
function.
So, in order to properly execute the index.js
file, we would need to include calculateBattleIndex.js
and calculatePower.js
in the following order.
<script src="calculateBattleIndex.js"></script>
<script src="calculatePower.js"></script>
<script src="main.js"></script>
If we mess up with the order (that is, if we chose to include calculatePower.js
before calculateBattleIndex.js
), then we might get a function undefined error.
But our project might not be as simple and small, so managing dependencies would be a hell of a task. That is one reason why people have started moving to component-based libraries built on javascript, like React.js and Angular.js, because they offer built-in modules to compile code.
Let's see how React does it.
02 Advantages of Webpack over react-scripts of CRA
I'm sure people who've worked on React might already know create-react-app
, which has some built-in react-scripts
to run the app, to make a production build, or to even test it.
But one major problem is that these are built-in script commands, so they are not really customizable. This is where you'll really feel the need to substitute it with Webpack.
Here are some more advantages of Webpack that I've come across:
Configurability
create-react-app
offers you minimum configuring build settings. They go by 'You Might Not Need a Toolchain' in their official doc. Although there is a way - by running npm eject
to get all the configuration files and editing them yourself - you'll still feel like it takes away the control Webpack provides, where you can really play with different environment configurations as per your needs.
SSR (server side rendering)
Ultimately server-side rendering is very hard to add in a meaningful way without also taking opinionated decisions. We donβt intend to make such decisions at this time. β Dan Abramov
SSR on a create-react-app
is not only complex but it can't be done without the help of third-party support, and CRA's developers are not eager to add this feature either.
But it can be done with Webpack very easily (will not get into that in this post, but you can follow up here: https://blog.jakoblind.no/ssr-webpack-bundle/).
03 Setting up Webpack
You can install Webpack and its command-line interface by:
npm install --save-dev webpack webpack-cli
That's it.
Check your package.json
file to see dependencies getting added up there,
"devDependencies": {
"webpack": "^4.44.1",
"webpack-cli": "^3.3.12"
}
Now let's make configuration files - these are required to give sets of rules for how certain types of files will be treated during compilation and resolution (before making AST to parse on).
For now, I'm making a common configuration file, which will serve both the dev and the prod environments along with the already existing configurations in them (which I'll add later), and name it webpack.common.js
The directory structure will look somewhat like this:
root
|_src
| |_index.js
| |_calculateBattleIndex.js
| |_calculatePower.js
| |_images
|_configs
| |_webpack.common.js
|_dist
|_myProject.js
Supplying configurations to Webpack
Since Webpack needs configuration modules to bundle the code, let's make a basic config (inside webpack.common.js
), where Webpack takes in the index.js
file, and bundles it in the dist
directory.
// webpack.common.js
const path = require('path');
module.exports = {
entry: '.src/index.js',
output: {
filename: 'myProject.js',
path: path.resolve(__dirname, 'dist'),
}
}
Add the Webpack start script in package.json
"scripts": {
"start": "webpack --config webpack.common.js",
}
Now run, npm start
It's quite evident, myProject.js
in the final bundle which is generated by Webpack for the JS file. We can now remove all the other script(s) from our index.html
file and just use this generated bundle as the only source script.
<script src="dist/myProject.js"></script>
Analysing the bundle
This section of the file is quite interesting, as we can see the functions we made have been minified and have become an argument to the eval
function.
The minification is happening because Webpack will run our code in production mode by default. If we don't set the mode manually, the output will be minified.
To set the mode manually, add this to module.exports
mode: "development"
But even in development
mode, the argument inside the eval function is still minified, so by adding
devtool: false
in module.exports
we can make the content in the bundle readable just like the following screenshot.
On running through the code, you might have these questions in mind.
a) Why are we using path.resolve()
?
This is basically used to resolve the relative path for a particular system. For example, in my machine, the __dirname
(a node script to get the complete address of the current directory) is atulkumar/documents/src/dist
whereas in some other machine the root directory could be different.
b) What are entry and output points?
In this case, the root javascript file (index.js
) becomes the entry point, and the output file is the file generated by the Webpack (myProject.js
)
04 Loaders and Plugins
Loaders
Loaders are used by Webpack to pre-process files. This enables us to bundle static resources apart from javascript files as well. There is a well documented official Webpack doc where you can find a lot of different loaders and their use-cases.
I'll call out a few helpful loaders which, according to me, every project must have.
04.01 Loader for CSS css-loader
, style-loader
& sass-loader
These loaders will handle our CSS and SASS/SCSS files.
To install the loaders,
npm install --save-dev style-loader css-loader sass-loader
and add the following piece of code to the module.exports
module: {
rules: [
{
test: /\.scss$/,
use: [
"style-loader",
"css-loader",
"sass-loader"
]
}
]
}
Note: Here, the order of the use
array matters, and the loaders are applied on our CSS/SCSS file in the reverse order, i.e:
a) sass-loader
will be applied first which will pre-process the SCSS into CSS
b) and then css-loader
will turn CSS into Common JS
c) lastly, style-loader
will inject style straight into DOM
04.02 Loader for images and fonts, file-loader
Again, we will need to install it first,
npm install --save-dev file-loader
and add the following piece of code in the rules
array of module.exports
{
test: /\.(svg|png|jpg|gif)$/,
use: {
loader: 'file-loader',
options: {
name: '[name].[hash].[ext]',
outputPath: 'images/'
}
}
},
{
test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/'
}
}
]
}
Running through from the code...
- The test will receive a RegEx to match for the type of file (format).
- We can also pass an
options
object along with our loader to customize it further - here, I've set upname
andoutputPath
. -
[name]
extracts the name of the particular asset being processed.[hash]
is a unique hash appended after the dot. This has its own use, I'll talk about it a little later.[ext]
as by the name, extracts, and appends the extension of the asset. - We can also give a custom path for the generated asset type by defining the
outputPath
-
file-loader
resolvesimport
andrequire()
on a file and converts it into a URL.
04.03 Loader for JS or JSX, babel-loader
Install it with:
npm install -βsave-dev babel-loader
Also install the presets and plugins it requires, with:
npm install β-save-dev @babel/preset-env @babel/plugin-transform-runtime
{
test: /\.jsx?$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: ['@babel/plugin-transform-runtime'],
}
}
},
Running through from the code...
-
babel-loader
is basically used for transpilation. I'm sure you know why we need transpilation. -
Why did we exclude the
node_module
directory?While transpiling a
js
file or preprocessing and transpiling thejsx
we excluded thenode_module
directory. And we did this for a very good reason.When we serve javascript to Webpack or any other asset for that matter, to increase the performance we need to cut down on the amount of code (size of compilation) we give Webpack for transpiling, especially because it's a costly process. So we skip on anything that comes from
node_module
because these should already be runnable, without transpilation.But this doesn't necessarily hold true all the time - you may come across a certain third party library, which may require transpilation on your off days. Don't worry, this can be taken care of as well.
Imagine there are two modules amongst all the other modules -
module-1
andmodule-2
which need to be transpiled. We can simply modify ourregEx
to exclude these modules from being excluded for transpilation, or simply, to include them while transpiling.
exclude: /node_modules\/(?![module-1|module-2])/
Here, it will skip all the files in node_module except
module-1
andmodule-2
-
@babel/preset-env
Thanks to this preset, JS developers can write the latest JS code without worrying about browser support.
@babel/plugin-transform-runtime
enforces babel helper functions that help save on the code-size. (I would recommend you to read the official doc to know more since it's quite interesting: https://babeljs.io/docs/en/babel-plugin-transform-runtime)
Cache Busting
There are a lot of things a browser does in the background that we sometimes don't observe. But, caching is something most of us are familiar with. A browser caches certain assets like bundled JS, CSS bundles, or even images to reduce load-time for future visits. If you refresh a page and look at the network tab in the developer tools, you'll see all the calls the website makes to get the content.
Here is my final bundle file myProject.js
on a hard refresh
To compare here is the same file on a soft refresh (cache disable off)
Look at the difference in the size, astonishing right?
But there is a pitfall.
While caching helps improve the load-time of a website, it hampers the user experience. Whenever the content is loaded from the cache, the user won't see the latest content of our website if we've made an update, and we can't expect them to perform a hard refresh, or to clear the cache regularly.
So busting cache becomes important.
After digging a little deeper, I came to know that the browser depends on the file name when it caches it. So essentially, changing the file-name on every refresh should solve our problem.
But how do we do it?
[contenthash]
does it for us. It is basically a hash generated for extracted content.
Lets add it to the output file:
output: {
filename: 'myProject.[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
Note: We can replace the dot with a dash or any other special character, or we can just skip it altogether and simply write myProject[contenthash].js
. This will also work.
Let's start the Webpack again and check,
I've added a few fonts and images, but let's focus on the main bundle.
8dcb493e06ef82c4151b
has been appended to the name we provided in the output. This is the contenthash
, which like any other hash function gives us a unique hash value, which changes only when the content of any dependency in the bundle changes.
To put in simply, this works like a normal hash function - for a particular value as input the function will always return the same unique output.
Tip: You can also slice the hash to limit it to a certain number of characters only, using:[contenthash:6]
.
Now we have a unique name on every file change, so the browser will know when to request for this file and when to load it from the disk cache.
A good example to use cache busting would be in vendor.js
, where we bundle the code from all the third-party libraries, as it doesn't change frequently.
But how can we link a JS file with a random name in the <script>
, when it changes on every update?
Plugins! We can do it with the help of plugins!
Plugins
Plugins are used to customize Webpack's build process and they make Webpack much more powerful.
04.04 Linking bundles with names having random hash values - html-webpack-plugin
Let me start with a very important plugin html-webpack-plugin
, which will solve the problem of using [contenthash]
and linking the output bundle with the main HTML template file.
Let's first install this plugin by running:
npm install β-save-dev html-webpack-plugin
Include it in the webpack.common.js
file.
const HtmlWebpackPlugin = require("html-webpack-plugin");
Now add this to the module.exports
plugins: [new HtmlWebpackPlugin()]
This will make a new HTML file with a default <title>
tag and a <script>
tag linking to the output JS bundle. You'll see your final JS bundle already linked in this newly generated HTML file.
<script src='myProject.8dcb493e06ef82c4151b.js'></script>
But what if we already have an HTML file with some content in it? How do we link all our bundled assets to that particular file?
The answer is fairly simple,
html-webpack-plugin
lets us supply our own template using lodash templates
so that all the bundles can be sourced to this template.
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'index.html'),
})
],
04.05 Cleaning up unwanted build resources clean-webpack-plugin
Another really important plugin you can use in your production build is the clean-webpack-plugin
. Whenever you make a production build by running npm run build
, you would see new files piling up and increasing the collective size of the build directory. Only the files generated from running the latest npm run build
, will be important for us so why should we keep all the other extra files?
Well, we won't be keeping them with clean-webpack-plugin
.
Let's start by installing it,
npm install -βsave-dev clean-webpack-plugin
Remember, this would be useful for the production environment as there is no build made in the development mode, well there is but not in our project directory, Webpack makes it in the system memory and loads it from there.
So now the webpack.prod.js
will look like this:
const common = require('./webpack.common');
const { merge } = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
plugins: [new CleanWebpackPlugin()],
});
What clean-webpack-plugin
does is, it empties the build directory before making the new build resources. With this, you don't need to worry about the extra unused files now.
Live reloading - the webpack-dev-server
Suppose you change something in the code and on saving it, the website reloads automatically! Wouldn't that be cool?
webpack-dev-server
can do it for us and it's quite simple to add it up.
We just need to install it by running this command
npm install β-save-dev webpack-dev-server
and adding to the npm start
script in package.json
"scripts": {
"start": "webpack-dev-server --config src/config/webpack.common.js",
// other scripts.
}
Yass that's it, that will do the magic.
webpack-dev-server
uses webpack-dev-middleware
under the hood, which provides fast in-memory access to Webpack assets.
Note: webpack-dev-server
should be used in the development mode only.
Tip: You can add β-open
to the script to start the Webpack with opening a new window with localhost:[port]
every time you run npm start
.
Configuring according to the environment (dev/prod)
Like I discussed earlier in this post, we'll be making 3 separate files for webpack config:
One was already made - webpack.common.js
- let's make configs for both the production and the development environments too, and name them webpack.prod.js
and webpack.dev.js
.
Throwing some code in them:
// webpack.dev.js
const common = require('./webpack.common');
const { merge } = require('webpack-merge');
const path = require('path');
module.exports = merge(common, {
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'build'),
compress: true,
port: 3006,
},
});
// webpack.prod.js
const common = require('./webpack.common');
const { merge } = require('webpack-merge');
module.exports = merge(common, {
mode: 'production',
});
Running through from the code...
- To merge the common module we made earlier with the new ones, we need to install
webpack-merge
(npm install -βsave-dev webpack-merge
) and include it in both the files. -
mode
will govern the built environment for the Webpack -
devServer
is a set of options picked bywebpack-dev-server
.-
contentBase
holds boolean | string | array value stating the static file location. -
compress: true
will enable gzip compression -
port
is thelocalhost
port to serve the website content on
-
Now, in package.json
add a build script that would generate the build resources for the production environment.
"scripts": {
"start": "webpack-dev-server --config src/config/webpack.dev.js --open",
"build": "webpack --config src/config/webpack.prod.js",
}
05 Optimisations
Before running straight into optimizing Webpack, let us configure a super-cool plugin which will make the Webpack logs look prettier!
The webpack-dashboard
plugin.
Let's start by installing it,
npm install --save-dev webpack-dashboard
We'll require the plugin,
const DashboardPlugin = require('webpack-dsahboard/plugin');
Now adding the plugin in the config file and instantiating DashboardPlugin.
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'index.html'),
}),
new DashboardPlugin()
],
We need to edit the start script as well to make sure Webpack starts up with the webpack-dashboard
plugin.
"scripts": {
"start": "webpack-dashboard -- webpack-dev-server --config src/config/webpack.dev.js --open",
}
Run npm start
And Booyah!!
This is your new Webpack log screen ππ»
Note: Just so you don't get lost, these logs are from a different project where I'd already installed a lot more dependencies so that we can go forward with optimizations. A lot has to do with third-party libraries. With npm
you'll get all of your 3rd party dependencies nicely clubbed in the node_modules
directory.
Splitting chunks with splitChunks
As you can see in the above screenshot, the only bundle that was generated by the Webpack is squaremate.8dcb493e06ef82c4151b.js
, having a size of 4.42Mb.
Now consider this - if we have to change something in the code, Webpack will re-bundle the whole file again (not load it from the cache... because we did bust some cache, right?), and serve it to the browser.
On every change, the browser will be requesting a 4.42Mb of data. That is quite a significant, if not a huge, breach in performance.
But what is in this file that is making it so huge? Of course, the vendor (third party) libraries.
splitChunks
enables us to split this file into chunks according to our needs.
Let's configure the basic optimization for Webpack by splitting all types of chunks
optimization: {
splitChunks: {
chunks: 'all'
}
},
Run npm start
and notice the magic!
As you can see now we have got 2 bundles squaremate.b9351008d8c24dca3f91.js
[119.64Kb] and vendors~main.squaremate.dafe32322b2b203e53da.js
[4.31Mb]
Oh hell! This vendor bundle was hiding behind the main bundle and eating up resources of the browser. If you take a closer look at the module section of the logs, you can also infer which module is actually killing up the browser and you can provide special attention to that particular module.
While this little piece of code can do the magic for us, let's try to understand what is actually happening behind the scenes.
Inferences
- As per the official docs, there are certain rules according to which Webpack automatically splits chunks
- A new chunk can be shared OR modules are from the
node_modules
folder - New chunk would be bigger than 20kb (before min+gz)
- Maximum number of parallel requests when loading chunks on demand would be lower than or equal to 30
- Maximum number of parallel requests at the initial page load would be lower than or equal to 30
- A new chunk can be shared OR modules are from the
- As the vendor code tends to change less often, browsers can cache it and load it from the disk cache itself, rather than making calls for it every time we hit refresh.
- If you'll do the math you'll notice the gigantic reduction in the main bundle size here, with just the 3 lines of code we added. Isn't that commendable?
Well, this is just basic optimization. You can flex much more with the power that splitChunk
provides. I won't get into more details, but I'll link an insane blog post by David Gilbertson from New South Wales, on optimization by splitting chunks on a whole new level [spoiler alert: more bundles incoming...].
https://medium.com/hackernoon/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
(Highly recommended read)
Conclusion
Webpack takes away the worry of managing resources for a front-end developer. You'll know how smart it is in managing them efficiently only if you choose to go into the depths of it.
The underlying aspects are quite interesting to move forward with, and it's only fair for us to know what has been going behind the scenes because of the sheer power it harbors and gives away to a developer. Do I sound like Alfred from Batman, Master Wayne?
In this particular blog, I tried to give justice to a few of Webpack's important features and tried to explain concepts from my point of view.
- We started with why is it even important to have Webpack in our project. The smallest example I could think of, still yielding a huge takeaway. There will be more such examples you'd come across, which would help answer your why's.
- We covered the reasons for why would you want to switch to Webpack from a more native react-scripts, when you stumble upon a react project.
- We set up our configuration files for the production and development environments and threw in some loaders and plugins in the process.
- We talked about how could cache busting solve our caching problems.
- We also talked briefly about the optimizations that Webpack provides and how can we save up on the load-time of our website.
Top comments (2)
Great article!
Thanks a lot Steve!