Introduction
I have probably spent a week or two looking for some "best practices" for
this problem: How to set up a Webpack for multi-page apps or websites?
I haven't found many resources on the internet, so I decided to dedicate
a blog post to it. Hopefully, you will find this helpful. So let's start!
Project Setup
Go on and create flask-with-webpack
to try this out.
The project structure should be like this.
flask-with-webpack
|- app
| |- __init__.py
| |- asset.py
| |- templates/
| | |- index.html
| |- static/
|- assets/
assets
folder will be where our unbundled frontend code
leaves. app
folder will contain our python code.
This will be our boilerplate:
| from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template("index.html")
if __name__ == "__main__":
app.run()
|
And our jinja2 template should look like this:
1
2
3
4
5
6
7
8
9
10
11
12 | <!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>Flask With Webpack</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
|
Let's do the frontend now: Go into the assets folder and run:
If npm and node are correctly installed, this should create
package.json
inside the assets
folder. While you are still
there, please run the following command to install our dev dependencies.
npm install -D webpack webpack-cli
Our webpack config file should be like this:
| const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, '..','app', 'static', 'dist'),
clean: true
},
};
|
Pay attention to the 7th line; we directed the output to the static
folder of our flask app, so our bundled assets go there.
Now let us update our package.json
:
{
"name": "assets",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack --mode=development",
"watch": "webpack --mode=development --watch",
"prod": "webpack --mode=production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1"
}
}
We added dev, prod, and watch scripts. This is just
a convenient way to run a webpack. We are going to use the watch script
mostly during our development process.
Also, we need to create our entry point. Let it be something simple:
console.log("Hello World");
Let's run npm run dev
to test this out. If you did
everything well far, app/static/dist/main.js
should be created.
From this point, one could argue that we are almost there,
and now all we have to do is to call url_for("static", filename="dist/main.js")
inside our template, but that won't help us with problems on
the production environment where we still have to deal with the
cache-busting.
Cache busting
Cache busting is the process of uploading a new file to replace
an existing file that is already cached. We should apply this in the
production environment, where current users have old static files cached,
and if we changed something in these files
(images, CSS or JS) we expect that to reflect
on their browsers.
app-name
|- app
|- __init__.py
|- templates/
| |- index.html
|- static/
|- dist/
|- main.js
|- assets
|- webpack.config.js
|- package.json
|- package-lock.json
|- node_modules/
|- src/
| |- index.js
Webpack manifest plugin
will help us deal with this issue, alongside the flask extension we will create. Let's install the webpack
plugin first in our assets folder.
npm install -D webpack-manifest-plugin
Then, update webpack.config.js
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | const path = require('path');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
publicPath: '/static/dist/',
path: path.resolve(__dirname, '..','app', 'static', 'dist'),
clean: true
},
plugins: [
new WebpackManifestPlugin()
]
};
|
First, we import the plugin in add it to the plugins section.
Next, we update filename
and publicPath
inside webpack output.
Run npm run dev
again. In our app/static/dist
folder,
there should be a new file, manifest.json,
with content similar to this:
{
"main.js": "/static/dist/main.a515dae3a1276df56c7b.js"
}
Let's now create a simple extension that uses this file
that helps us load the correct content.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 | import json, os
from flask import current_app
class Asset:
def __init__(self, app=None):
self.app = app
self.assets = {}
if app is not None:
self.init_app(app)
def init_app(self, app):
self.manifest_path = os.path.join(app.static_folder, "dist", "manifest.json")
self._get_webpack_assets(app)
if app.config.get("DEBUG"):
app.before_request(self.reload_webpack_assets)
app.context_processor(lambda: {"asset": self})
def url_for(self, file):
return self.assets.get(file)
def reload_webpack_assets(self):
self._get_webpack_assets(current_app)
def _get_webpack_assets(self, app):
with app.open_resource(self.manifest_path) as manifest:
self.assets = json.load(manifest)
|
Code should be self-explanatory, tricky parts are these highlighted
lines. We want to make sure that if the app runs in development mode
before each request, we reload manifest.json cause content could
change in the meantime. We also want to make the extension available in
templates, so we use app.context_processor
.
Now we have to initialise this extension like this:
1
2
3
4
5
6
7
8
9
10
11
12
13 | from flask import Flask, render_template
from app.asset import Asset
app = Flask(__name__)
Asset(app)
@app.route('/')
def index():
return render_template("index.html")
if __name__ == "__main__":
app.run()
|
And our last step is to use the asset extension
in our index.html
like this:
| <script src="{{ asset.url_for("main.js") }}"></script>
|
And that's it! If you get this far, you should
have this code working. From this point, you can add new plugins
to your webpack and tweak it to work in different ways. I would
recommend adding CleanWebpackPlugin and MiniCSSExtractPlugin first.
Bonus - handling CSS
Let see how we can use Webpack to compile our styles. First, we are going to
create styles.scss
cause we want to extend Bulma, for example.
app-name
|- app
|- __init__.py
|- templates/
| |- index.html
|- static/
|- dist/
|- main.js
|- assets
|- webpack.config.js
|- package.json
|- package-lock.json
|- node_modules/
|- src/
| |- index.js
| |- styles.scss
We need to install some new packages:
npm install bulma
npm install -D css-loader sass-loader node-sass mini-css-extract-plugin
Let's copy-paste Bulma example
for sake of this tutorial.
@charset "utf-8";
// Import a Google Font
@import url('https://fonts.googleapis.com/css?family=Nunito:400,700');
// Set your brand colors
$purple: #8A4D76;
$pink: #FA7C91;
$brown: #757763;
$beige-light: #D0D1CD;
$beige-lighter: #EFF0EB;
// Update Bulma's global variables
$family-sans-serif: "Nunito", sans-serif;
$grey-dark: $brown;
$grey-light: $beige-light;
$primary: $purple;
$link: $pink;
$widescreen-enabled: false;
$fullhd-enabled: false;
// Update some of Bulma's component variables
$body-background-color: $beige-lighter;
$control-border-width: 2px;
$input-border-color: transparent;
$input-shadow: none;
// Import only what you need from Bulma
@import "../node_modules/bulma/sass/utilities/_all.sass";
@import "../node_modules/bulma/sass/base/_all.sass";
@import "../node_modules/bulma/sass/elements/button.sass";
@import "../node_modules/bulma/sass/elements/container.sass";
@import "../node_modules/bulma/sass/elements/title.sass";
@import "../node_modules/bulma/sass/form/_all.sass";
@import "../node_modules/bulma/sass/components/navbar.sass";
@import "../node_modules/bulma/sass/layout/hero.sass";
@import "../node_modules/bulma/sass/layout/section.sass";
Finally, we need to update our webpack.config.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 | const path = require('path');
const {WebpackManifestPlugin} = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: {
main: './src/index.js',
styles: '/src/styles.scss'
},
output: {
filename: '[name].[contenthash].js',
publicPath: '/static/dist/',
path: path.resolve(__dirname, '..', 'app', 'static', 'dist'),
clean: true
},
module: {
rules: [{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader'
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
}
}
]
}]
},
plugins: [
new WebpackManifestPlugin(),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
})
]
};
|
So first, we import MiniCssExtractPlugin and add it to
the plugins section. Then, we add a new entry - styles
in the appropriate area.
I won't go into explaining the rules
section cause you
could check official webpack documentation about that.
Now, if we run npm run dev
new style file will be
generated and manifest.json
will look
something like this:
{
"main.js": "/static/dist/main.544743f724b236ee60fe.js",
"styles.css": "/static/dist/styles.3a4a4455de45c7b54bde.css",
"styles.js": "/static/dist/styles.31d6cfe0d16ae931b73c.js"
}
But wait, in this process, we also got styles.js
that
appears to be empty. This is a drawback of Webpack, but
fortunately, the webpack-remove-empty-scripts
plugin will eliminate empty
js
files. So let install it.
npm install -D webpack-remove-empty-scripts
Update your webpack.config.js
:
const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts')
module.exports = {
// ...
plugins: [
// ...
new RemoveEmptyScriptsPlugin(),
// ...
]
};
Now, if we run npm run dev
again, styles.js
will
disappear, and there won't be any junk!
We can include these styles in the same way we did with
the main.js
script.