Django and Webpack - Bundle Caching, Multiple Bundles, and the HtmlWebpackPlugin
In this post, we'll cover some more advanced Webpack concepts - generating multiple bundles (vendor/3rd party vs source bundles) with Code Splitting, using content hashes in bundle names for caching, and using the HtmlWebpackPlugin.
We will build on concepts from the previous posts - you can find the first post in this series here.
The associated video for this post is shown below:
Objectives
In this post, we'll learn how to:
- Generate multiple bundles using Webpack
- The benefits of splitting third-party dependencies into their own bundles
- How to use the
[name]and[contenthash]substitutions for output filenames - How to use the
HtmlWebpackPluginto automatically inject bundle filenames that may change frequently into a template - How to utilize Webpack for caching when using multiple bundles
Generating Multiple Bundles
Let's start by understanding how to generate multiple output bundles during a Webpack build process. We might want to do this for many reasons - for example, we might include a third-party library such as HTMX or Alpine.js in a vendor bundle that will not change much, since we do not typically alter the source code of these external packages.
Thus, we want to separate that vendor bundle from our source code bundle, because (as we'll see later) this can help us with caching and performance.
The simplest way to do this is to define a second entry-point in our Webpack configuration. So let's start by creating a new file in our assets/scripts folder, called vendor.js. We'll move the third-party stuff (HTMX and Alpine) into that file, as below:
import Alpine from 'alpinejs'
// Add Alpine object to the window scope
window.Alpine = Alpine
// initialize Alpine
Alpine.start()
// import HTMX and inject it into the window scope
window.htmx = require('htmx.org');
window.addEventListener('load', () => {
document.getElementById('message').textContent = 'REBUNDLED BY WEBPACK!';
});
Also, let's clean up the index.js file to remove references to the vendor-packages.
import './helper1';
import './helper2';
import '../styles/index.css'
import './calculator.ts';
import TestImage from '../images/test-image.jpeg';
window.addEventListener('load', () => {
const myImg = new Image();
myImg.src = TestImage;
myImg.style.width = "200px";
myImg.style.height = "200px";
document.body.appendChild(myImg);
})Let's now modify the entry and output blocks of our Webpack configuration. We're going to add two entries, and we're going to make use of a substitution in our output filename (more on that in a second).
Add the following code:
module.exports = {
entry: {
index: './assets/scripts/index.js',
vendor: './assets/scripts/vendor.js'
},
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].js'
},
...
}On lines 2-4, we define an entry object, with two links to different JavaScript files - our original index.js file, as well as the new vendor.js file.
We've also modified the output block to add the [name] substitution - rather than hard-coding the bundle name, each separate bundle (now that we'll have two, since we have two entries) will have the same name as defined in the entry object.
Now, delete the bundle.js from your static directory. We're going to run Webpack with the new configuration, so run this: npm run dev.
This should generate two output bundles in your static directory - one called index.js, and another called vendor.js. So we have two outputs!
Now, let's include these in our index.html Django template. We can remove the reference to the old bundle.js, and replace with the following code:
<script src="{% static 'index.js' %}"></script>
<script src="{% static 'vendor.js' %}"></script>Verify that your page still works as expected. We've now separated out the third-party code from our source-code, and have learned how to include multiple entry files in order to generate multiple output bundles. This has some benefits that we'll see soon!
HtmlWebpackPlugin - to Automatically Add Bundles
Notice how we had to change the names of the files generated by Webpack in our index.html template. What if we change the name of the bundle? We'll have to dive into the base template each time and change the filename for that script. Inconvenient!
This is even more inconvenient when we use hashes in filenames, which often change - as we'll see soon. Every time Webpack generates a new bundle, we'd need to change our links.
We can use the HtmlWebpackPlugin to remedy this. This is an example of a Webpack plugin - as the documentation states, plugins can be leveraged to perform a wider range of tasks than loaders, such as bundle optimization, asset management and injection of environment variables.
Firstly, let's install HtmlWebpackPlugin with the following command:
npm install --save-dev html-webpack-plugin
After we've installed this, add it to our Webpack configuration under the plugins array, as below (line 16).
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './assets/scripts/index.js',
vendor: './assets/scripts/vendor.js'
},
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].js'
},
resolve: {
extensions: ['.ts', '...'],
},
plugins: [new HtmlWebpackPlugin()],
module: {
rules: [
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.ts$/i,
use: 'ts-loader',
exclude: '/node_modules/'
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
]
}
}Don't forget to import the plugin (line 2).
What this will do is generate an index.html file within your output directory, that contains pre-populated links to the two output bundles. This means we don't have to manually change these links whenever we change our output names, or add new bundles to our output.
But how can we include this in our Django project? As it stands, we're still manually defining the links to our bundles in the index.html.
The HtmlWebpackPlugin allows us to define a template for our generated index.html - we're going to do that now. In your templates directory, create a file called base_webpack.html with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Django Webpack template</title>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>
What the plugin will do is take this template, and inject the generated <script> tags from the Webpack build process. We want the output of this to go into a base.html template within the templates directory. So let's tell the plugin about this template, and about the location of the output HTML file.
Amend the Webpack configuration file to add the following settings for the plugin:
plugins: [new HtmlWebpackPlugin({
template: './core/templates/base_webpack.html',
filename: '../templates/base.html'
})]We point the plugin to our new base_webpack.html template, and tell it that its output should go (relative to the build directory, i.e. the core/static directory) to the core/templates directory, in a file called base.html.
This should generate this file, and if you inspect its contents after running Webpack's build, you should see that it has automatically injected the two bundle scripts!
This can serve as our base template, and others can extend it. So let's modify our index.html (that we've been working with up until now) to extend this, and define a block for its content.
Add the following code to the index.html:
{% extends 'base.html' %}
{% block content %}
<p id="message">Hello</p>
<button hx-get="{% url 'index' %}" hx-trigger="click" hx-swap="outerHTML">Click me</button>
<hr />
<div x-data="{showMe: false, msg: 'Hello from Alpine'}">
<p x-show="showMe" x-text="msg"></p>
<button @click="showMe = !showMe">Toggle</button>
</div>
<hr/>
<div>
<input id="num1" type="number" />
</div>
<div>
<input id="num2" type="number" />
</div>
<button id="addBtn">Calculate Sum</button>
<p id="result"></p>
{% endblock %}This is the same code that lived in the body previously, but now we extend the base template and only define the content block. The bundles are automatically loaded in the base template.
This has given us the benefit of being able to change the names of our generated bundles, and the HtmlWebpackPlugin will handle the injection of the correct bundle name into our template. To demonstrate this, change the files generated in the Webpack configuration by altering the names of the input keys, as below:
module.exports = {
entry: {
index_CHANGE: './assets/scripts/index.js',
vendor_CHANGE: './assets/scripts/vendor.js'
},
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].js'
},
...
}This changes the output names, since they use the [name] substitution.
Run the Webpack build process, and inspect the base.html that was generated. The script tags should reflect these change to the filenames in our output directory - very convenient!
Feel free to change the entry names back to what they were before, now that we've demonstrated this.
Cleaning Outputs
If you inspect the output directory, you should see that we're beginning to get a lot of file names, many of which are no longer used because we've changed the name of the output bundle.
This issue becomes more pronounced when we use hashes in our filenames (later), which leads to a new bundle every time the source code changes.
There's a very simple mechanism for cleaning the output directory. We can add a clean key to our output object in the Webpack configuration, as below:
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].js',
clean: true,
}We set this to true. Now, run the Webpack build again with npm run dev and you should see that the output directory is cleaned up, and contains only the files in-use.
Webpack Caching
With our current setup, we have two entry-points, and generate a two outputs.
These output artefacts are deployed to our Django server, and clients fetch them when they visit our application.
Fetching static resources that don't often change, such as JavaScript and CSS files, as well as files such as images and fonts, should not re-occur every time a request comes into our webpages. Browsers deal with this by caching static files for a specific time, and this can help our pages load much more quickly.
The downside to caching is that, if we change any source code and deploy a new bundle, browsers may not know anything has changed - our output filenames do not change in our current setup (even if the source code has changed on a new build), thus when a browser sees that it should fetch these filenames, it may choose instead to use a cached version, which does NOT contain our changed source code.
We'll address this issue now!
To generate the output filenames, we currently have this block in our Webpack configuration file.
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].js',
clean: true,
}We use the [name] substitution to switch in the name from the entry file to our output bundles. Webpack has another substitution that we'll use here, called the [contenthash] substitution.
This does what you would expect, given its name - it looks at the content of the generated bundles, and creates a hash based on the contents.
This has a nice benefit: if the output of only one of your bundles changes, but the others remain constant, then a new hash will only be generated for the bundle that has changed! Thus, browsers will know that they need to send the request for that new bundle (since the filename has changed and no longer has the same hash), but for the bundles that have NOT changed, they can use the versions in the browser cache.
So adding a hash of the contents has benefits for caching. Let's see this in action: add the following to the output.filename setting in the Webpack configuration:
output: {
'path': path.resolve(__dirname, 'core', 'static'),
'filename': '[name].[contenthash].js',
clean: true,
}We've added the [contenthash] substitution, so let's rebuild the Webpack bundles with the npm run dev command.
If you inspect the output in the core/static directory, you should now see a hash has been added to the bundle filenames!
Furthermore, since we set up the HtmlWebpackPlugin, we should have the new filenames containing the hash reflected in our base.html template. This is a major benefit to this plugin - hashes may change a lot more often than the filenames, thus we no longer have to manually update our script tags to deal with this.
Let's open our dummy webpage and see how this affects the caching. With the Django server running, open the browser's developer tools on the network tab, and navigate to localhost:8000. Since our bundle files now contain the content-hash, the browser will send a request to the Django server to fetch these files, since it has never seen these filenames before.
Now, if we refresh, the browser should use its cached versions of the files, as long as the filename remains the same. After refreshing, the network tab looks like the following:
The two scripts return a 304 Not Modified response, this time. This tells the browser that it can use the cached version of these files, which is much faster than sending a network request to fetch the scripts from a remote server!
The benefit of this is amplified if you are generating large bundles with lots of third-party JavaScript packages. Instead of fetching on every request, your browser can cache the vendor code locally to boost performance. This is also important on mobiles with limited data - you're caching, thus using less of your mobile data every time you visit a site whose vendor code doesn't change often.
Now, a common scenario in web development would be the following: you're using third-party JavaScript packages such as HTMX or React, and you also have local source code. The source code changes often, but you won't be modifying the third-party (vendor) code often, unless you're adding a new package.
So let's simulate this: we'll modify our index bundle, but keep the vendor bundle the same. Go to index.js entry-point and add any new code (I'll add the following, on the bottom two lines):
import './helper1';
import './helper2';
import '../styles/index.css'
import './calculator.ts';
import TestImage from '../images/test-image.jpeg';
window.addEventListener('load', () => {
const myImg = new Image();
myImg.src = TestImage;
myImg.style.width = "200px";
myImg.style.height = "200px";
document.body.appendChild(myImg);
})
let message = "I'm changing source code"
console.log(message)
Now, Webpack should re-build your assets. In the output directory, notice that the index bundle's hash has changed - because the contents of the code used to generated the bundle have changed!
On the other hand, the vendor bundle has the same hash. Nothing has changed here.
Now, the HtmlWebpackPlugin will take care of injecting the new index contenthash into the base.html template. So let's go back to the browser and refresh our page. You should see something like the following:
This is the key: the index bundle with our source code has been re-fetched from the server, because the page is looking for the file with the new hash, that the browser has never seen before.
On the other hand, the vendor bundle has not changed, thus the hash generated from its contents hasn't changed. So the browser uses the cached version of this file, and no new network request is initiated.
This is the benefit of the contenthash substitution, and this should be used in your production Webpack code: it allows you to intelligently cache your bundles.
Summary
In this post, we've covered some slightly more advanced concepts. We now know how to generate multiple bundles, and why we may want to do so. We also know how to use content hashes in our output filenames, which can then be used to intelligently cache our bundles.
We've also learned how to use the HtmlWebpackPlugin to automate the references to our bundle outputs in a base template that can then be used by Django.
In the next posts, we plan to briefly cover setting up Django with different front-end dependencies, including HTMX/Alpine/Tailwind, React.js, Vue.js and Svelte.
If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!
Please also consider buying us a coffee, to encourage us to create more posts and videos!