Practical WEBPACK Basics: Pure Javascript frontend
steffanboodhoo
Posted on June 20, 2022
Contents
- Inspiration
- Prerequisites
- Setup
- Webpack basics
- Sample App and Bundling
- Configurations
- Development
- Module and Loaders
- Extra Bits
Inspiration
About two years ago I created a Webpack setup to suit my specific needs when developing with react. Since then I've mainly been on large projects and because I used react, I never needed to revisit. Recently I decided to do some small experiments and found myself struggling a bit to create a pure Javascript Webpack setup. I didn't want to copy someone else's without actually understanding what it is it was doing. So after revisiting in some depth here I am, creating a guide that's would be easy to digest, practical and would maybe even serve me later down if I eventually forget and need to return 😅.
Prerequisites
To start you'll need Node, npm and preferably npx, you can skip to the next section if you already have these installed ( next section )
Installing Node
OPTION A: (Recommended NVM ( Node Version Manager)
It's generally recommended that you use nvm to install and manage versions of node. You can see instructions on how to install for you OS here. Definitely use the above link if you can, however if not you can try running these...
install via curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
reload your terminal
source ~/.bashrc
check the install
nvm -v
use nvm to install a version of node ( e.g. 16 )
nvm install 16
OR
use nvm to install the latest version of node
nvm install node
use nvm to use a version of Node it installed ( e.g. 16 )
nvm use 16
OPTION B: Direct Install
You can visit here for installation instructions for your specific OS.
npm and npx
npm and npx are usually installed alongside node, you can test with npm --version
and npx --version
.
Note: Node, npm and npx are all different things, Node is the execution environment ( basically the thing that runs the code ); npm, Node Package Manager, manages packages for node; npx, Node Package Execute, allows us to run installed node packages. The versions of each of these things are (mostly) independent and therefore when you run npm --version
or node --version
or npx --version
DON'T EXPECT to see the same number.
Dependending on which option you chose npx may not be installed, as such you can run the following:
install npx globally ( DO NOT RUN IF YOU ALREADY HAVE npx INSTALLED, again check with npx --version
)
npm install -g npx
Setup
Create a folder for our tutorial, let's say sample-frontend
, go into sample-frontend
and initialize this folder by running npm init
, this will take you through some questions to setup your project.
Once you're finished you can create a sample_index.html
file then a src
folder and inside src
create a sample_index.js
file.
Right now your folder should look something like this
sample-frontend
|- package.json
|- sample_index.html
|- /src
|- sample_index.js
sample files
sample_index.html
<!DOCTYPE html>
<html>
<head></head>
<body>
I am body !
<script type='text/javascript' src="./src/sample_index.js"></script>
</body>
</html>
sample_index.js
window.onload = ev => {
init();
}
const init = () => {
console.log('hello I am a sample index.js');
}
Check your page and ensure everything is okay by visiting //sample_index.html on your browser.
Webpack basics
What is Webpack ? well according to the official documentation
At its core, webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph from one or more entry points and then combines every module your project needs into one or more bundles, which are static assets to serve your content from.
To generalize and simplify ( for starters )
Webpack looks at all your code and outputs a single something.
Some questions that you might ask are (1) How does it work (2) what does it output (3) how is this helpful
How does it work and what does it output
Let's install webpack and find out.
Install webpack and it's cli
npm install --save-dev webpack webpack-cli
navigate to the root of your project sample_project
and run the following:
npx webpack --entry ./src/sample_index.js --mode production
This command and it's output tells us a lot. It says please take a look at my code starting from sample_index.js
and output something that's ready for production.
If we look at our project directory we'll see a folder dist
and inside a file main.js
.
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- /src
|- sample_index.js
|- /dist
|- main.js
|- /node_modules...
main.js
should contain something like this
(()=>{window.onload=l=>{o()};const o=()=>{console.log("hello I am a sample index.js")}})();
main.js is minified and obfuscated version of our code base. ( Minification - helps load time by removing unnecessary characters from code; Obfuscation - helps protect your code/logic by making it difficult to read/understand )
We can edit our sample_index.html
to use main.js
and it will work in the same way.
sample_index.html
<!DOCTYPE html>
<html>
<head></head>
<body>
I am body !
<!-- <script type='text/javascript' src="./src/sample_index.js"></script> -->
<script type='text/javascript' src="./dist/main.js"></script>
</body>
</html>
( you can test by visiting your page in the browser )
There's something else going on ( the main thing actually 😅 ) but we need to add dependencies to see it in action. We'll add two dependencies, one being a node package we install and another being our own, we will use these to build an art search app.
Sample App and Bundling
Note: Since this tutorial isn't about writing code itself, explanations on how the app works will be documented in the comments within the files themselves.
Let's add our first dependency, a package to help us make requests
npm install --save axios
Now our own, in src
create a file art_helper.js
.
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- /src
|- sample_index.js
|- art_helper.js
|- /dist
|- main.js
|- /node_modules...
Edit your art_helper.js to include the following:
import Axios from 'axios';
export const searchArt = ({ title }) => {
const params = { q: title, limit: 5, fields: 'id,title,image_id,artist_display' }; // sample set of params, limits the number of results to 5, and only returns the id, title, image_id, and artist_display fields
return Axios.request({
url: 'https://api.artic.edu/api/v1/artworks/search',
params
}).then(res => {
const { config, data } = res.data;
const artPieces = data.map(artPiece => ArtPiece({ config, ...artPiece })); // map the data to an array of HTML strings where each HTML string is an art piece (see ArtPiece function below)
return { artPieces };
}).catch(err => {
console.log(err);
});
}
// Takes a config and the data for an art piece and returns an HTML string
const ArtPiece = ({ config, title, image_id, id, artist_display }) => {
const image_url = `${config.iiif_url}/${image_id}/full/843,/0/default.jpg`;
console.log(image_url);
return (`<div class='art_piece'>
<img src="${image_url}" />
<h3>${title}</h3>
<p>- ${artist_display}</p>
</div>`);
}
/**
* Fetches art from The Art Institute of Chicago API ( https://api.artic.edu/docs )
* First queries the API for art pieces with the given title ( https://api.artic.edu/api/v1/artworks/search )
* Then returns an array of ArtPiece objects
* The image URL for each ArtPiece is constructed from the IIIF URL ( config.iiif_url ) and the image_id
*/
Edit your sample_index.html
to reflect below:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id='root'>
<input type='text' id='title-search-input'/>
<button id='title-search-button'>Search</button>
<div id='search-results'>
</div>
</div>
<!-- <script type='text/javascript' src="./src/sample_index.js"></script> -->
<script type='text/javascript' src="./dist/main.js"></script>
</body>
</html>
Finally Edit your sample_index.js
to the following:
import { searchArt } from './art_helper';
window.onload = ev => {
init();
}
const init = () => {
console.log('hello I am a sample index.js vn');
document.getElementById('title-search-button').addEventListener('click', handleSearchClick);
}
const handleSearchClick = (ev) => {
const title = document.getElementById('title-search-input').value;
searchArt({ title }).then(renderResults);
}
const renderResults = ({ artPieces }) => {
document.getElementById('search-results').innerHTML = artPieces.join('');
}
Once more at the root of your project run
npx webpack --entry ./src/sample_index.js --mode production
then go to your page refresh and try it out.
We now have a bare bones art search app, a couple things to note: We did not have to use script tags in our sample_index.html
for our dependencies, art_helper.js and Axios, along with that we did not have to worry about the order in which they were included. Also if we view our main.js file, though minified and obfuscated making it difficult to read, we see that our ES6+ syntax has been translated down allowing us to use all of ES6+ without having to worry about compatibility issues across browsers.
Starting with our entry point sample_index.js
our code has been thoroughly parsed all the while building a graph of our exact dependencies to bundle, transpile (ES6+ JS to browser compatible version), obfuscated and minified into one file main.js
with which we can use.
Configurations
Running quick commands are fine however when we want to specify and do a lot more, configuration files become a necessity, especially when working with others, it's easier to debug a config file than an exceptionally verbose command. Let's translate our build command into configuration, in the project directory sample-frontend
create a file webpack.config.js
.
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- webpack.config.js
|- /src
|- sample_index.js
|- art_helper.js
|- /dist
|- main.js
|- /node_modules...
Edit webpack.config.js to reflect the following:
const path = require('path');
module.exports = {
entry: {
main: './src/sample_index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
},
mode: 'production'
}
Now you can run npx webpack --config webpack.config.js
and achieve the same result.
What's happening here ? We're exporting an object that describes what we would like to happen.
entry
Where should Webpack start, which, can be rephrased as where "does my app start ?", in this case './src/sample_index.js'.
You'll notice entry is an object ( a set of key value pairs ) and we've called our entry point 'main', this is because by default 'main' is name given to the bundled javascript file. However we can rename this to whatever we like, a popular name or term is simply 'bundle'.
const path = require('path');
module.exports = {
entry: {
bundle: './src/sample_index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
},
mode: 'production'
}
If you run npx webpack --config webpack.config.js
now in the dist folder you'll see two files main.js
and bundle.js
. Now we have two problems with this (1) if we generate a file with a different name the old one stays and (2) our sample_index.html
will need to be updated to load our new file name. We can solve one by configuring the next section of the configuration object output.
output
In output we describe what we would like webpack to output (crazy right ?). Currently we have one specification here, the path, i.e. where do we want our bundled file to be output, path.resolve(__dirname, 'dist')
which means the get the absolute path to the current directory then output to folder dist.
Now after reconfiguring entry above we saw that the old files generated under a different name are just left there, to fix this we have to tell webpack to clean up after us.
const path = require('path');
module.exports = {
entry: {
bundle: './src/sample_index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
mode: 'production'
}
Now if we run npx webpack --config webpack.config.js
we'll see that main.js
was removed, however we're still left with the other problem of having to edit sample_index.html
whenever the bundled filename changes. To get around this we move to a new section plugins
plugins
Plugins like the name suggest can add a wide array of functionality, in this case we would like to tell webpack to generate an index.html file for us and automatically inject our generated bundle into this file.
First we need to install the plugin needed for this html-webpack-plugin
npm install --save-dev html-webpack-plugin
Now we can edit out webpack.config.js
to use the html-webpack-plugin
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
}
}
As shown above, plugins accepts a LIST of plugin objects, we only have one, our html-webpack-plugin which is configured to use our sample_index.html
as a template for creating a new file called index.html
which will contain a script tag that correctly links whatever generated bundle. This means we must edit our sample_index.html
and remove our manual efforts of doing such.
sample_index.html
now becomes
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id='root'>
<input type='text' id='title-search-input'/>
<button id='title-search-button'>Search</button>
<div id='search-results'>
</div>
</div>
</body>
</html>
Now if we run npx webpack --config webpack.config.js
we'll see both our javascript bundle and an index.html file in dist
. Now you can open index.html
in the browser and test it out.
Development
So far we have been manually refreshing our page and re-opening html files, that's ridiculous, let's fix that. Instead of using some other tool and creating some complex pipeline of building with one then live & hot reloading using another, thankfully Webpack provides a solution within the same ecosystem webpack-dev-server
.
Let's install webpack-dev-server
npm install --save-dev webpack-dev-server
Now copy your current configuration into a new file called webpack.dev.config.js
and rename your old file webpack.prod.config.js
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- webpack.dev.config.js
|- webpack.prod.config.js
|- /src
|- sample_index.js
|- art_helper.js
|- /dist
|- main.js
|- /node_modules...
Edit webpack.dev.config.js
to reflect the following:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
devtool: 'inline-source-map', // allows error messages to be mapped to the code we wrote and not compiled version
devServer: {
static: {
directory: path.resolve(__dirname, 'dist'),
},
port: 3000,
host: 'localhost',
hot: true,
open: true
}
}
As you can see we added a devServer section to our configuration and in it described how we would like our development server to behave. Firstly static
tells it where to find our code i.e. the same place where our code builds to, host
and port
are self explanatory, setting hot
to true enables hot reloading ( keep the app running and to inject new versions of the files that you edited at runtime ), and finally setting open
to true will open the browser when we run this configuration.
We've also added a devtool inline-source-map, this maps output ( logs and errors ) to the source code instead of the bundle, this way we can actually use our console to debug.
Now we have two different configurations a dev and prod
we can start the development server by running
npx webpack serve --config webpack.dev.config.js
Your browser should open on localhost:3000 with your app running, now whenever edit your javascript files the app will automatically reload with the changes.
Now note that I specifically said javascript files, this is because as is webpack will only understand javascript and json files. This is why we haven't used any CSS for example, we can use CSS by doing it the normal way ( adding a tag for it ) but we would loose out on the optimizations that webpack would provide when building our output. To make utilize files we need to make use of another section module.
Module and Loaders
Webpack by default only accepts javascript and json files, loaders change this. It does this by adding the ability to load and process these other file types treating each almost as it were another javascript module. The ability to view these other files (e.g. a CSS file) as modules allows webpack to add these to their dependency graph and optimize our build.
Similar to plugins, there exists a wide array of loaders for various file types and the functionality. A loader, somewhat similar to plugin needs to be installed and then setup in our webpack configurations.
Let's add two loaders to help us with CSS
npm install --save-dev style-loader css-loader
Now let's use them in our configurations
first webpack.dev.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
module: {
rules: [
{ test: /\.css$/, use: ['style-loader','css-loader'] }
]
},
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
devtool: 'inline-source-map', // allows error messages to be mapped to the code we wrote and not compiled version
devServer: {
static: {
directory: path.resolve(__dirname, 'dist'),
},
port: 3000,
host: 'localhost',
hot: true,
open: true
}
}
then webpack.prod.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
module: {
rules: [
{ test: /\.css$/, use: ['style-loader','css-loader'] }
]
},
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
}
As you can see we added a module section, where we define rules on what files we want read and how we would like our loaders to be used on those files. The two parts of each rule test
and use
, tell us exactly which files to look at and which loaders to use respectively. For our rule test
basically says use any file that ends with '.css' and use the style-loader
and css-loader
to process these files. Rules and Loaders can be a bit tricky for example it executes in the order they are included in the configuration and a different ordering can produce a very different result.
Here some more documentation on loaders and heres a list of some loaders.
Let's add some style, create a style.css
in src
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- webpack.dev.config.js
|- webpack.prod.config.js
|- /src
|- sample_index.js
|- art_helper.js
|- style.css
|- /dist
|- main.js
|- /node_modules...
You can do whatever you like with style.css
tbh :P but here's a quick something in case you're busy
#title-search-input {
width:50vw
}
#title-search-button {
width:30vw
}
#search-results{
width: 100%;
text-align: center;
}
.art_piece{
width: 100%;
margin: 30px 30px 30px 30px;
padding:3%;
-webkit-box-shadow: 0px 10px 18px 2px rgba(0,0,0,0.67);
box-shadow: 0px 10px 18px 2px rgba(0,0,0,0.67);
}
(edit: thanks to cswalker21 for reminding me to show this step)
Finally edit sample_index.js
and import the style.css
like such
import './style.css';
I'm definitely not the best CSS guy so I encourage you to try out your own stuff.
Now let's run start our development server and see how it looks
npx webpack serve --config webpack.dev.config.js
We can also test our build
npx webpack --config webpack.prod.config.js
Now you're all set to start building and exploring on your own, the following are just some extra bits.
Extra Bits
Commands
In case you want to make things a bit simpler either for yourself or for those in your team, you can create a 'shortcut' for your commands using the scripts
section of your package.json
( aka npm sripts ).
For example instead of always writing npx webpack serve --config webpack.dev.config.js
to start our development server, we can create a 'shortcut command' for example npm run start
by editting the "scripts" section and adding a key "start" with value npx webpack serve --config webpack.dev.config.js
.
{
...
"scripts":{
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npx webpack serve --config webpack.dev.config.js"
}
...
}
So now we can run npm run start
to start our development server
We can also do something similar for building
{
...
"scripts":{
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npx webpack serve --config webpack.dev.config.js",
"build": "npx webpack --config webpack.prod.config.js"
}
...
}
Now we can run npm run build
to build our app
TypeScript
Always wanted to try typescript but didn't know how to transpile to javascript for development or even building, let's do it.
Let's install what we need typescript itself and a loader for webpack
npm install --save-dev typescript ts-loader
Now let's add a rule to load .ts ( typescript ) files and also tell webpack what types of files we need to resolve ( module resolution deals with where to find what at build time ). ( see module resolution )
First webpack.dev.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
module: {
rules: [
{ test: /\.css$/, use: ['style-loader','css-loader'] },
{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }
]
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
devtool: 'inline-source-map', // allows error messages to be mapped to the code we wrote and not compiled version
devServer: {
static: {
directory: path.resolve(__dirname, 'dist'),
},
port: 3000,
host: 'localhost',
hot: true,
open: true
}
}
then webpack.prod.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
bundle: './src/sample_index.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './sample_index.html',
filename: 'index.html'
})
],
module: {
rules: [
{ test: /\.css$/, use: ['style-loader','css-loader'] },
{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }
]
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
clean: true
},
}
Finally we need to add a tsconfig.json
to sample-frontend
( tsconfig documentation ) to describe how our typescript should be compiled
sample-frontend
|- package.json
|- package-lock.json
|- sample_index.html
|- webpack.dev.config.js
|- webpack.prod.config.js
|- tsconfig.json
|- /src
|- sample_index.js
|- art_helper.js
|- style.css
|- /dist
|- main.js
|- /node_modules...
tsconfig.json
{
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,
"module": "es6",
"target": "es5",
"allowJs": true,
"moduleResolution": "node"
}
}
Congrats now we can write write typescript, as an example create validation_helper.ts
in src
and add the following:
export const validateSearchQuery = (title: string, limit: number, offset: number) => {
if(title.length == 0)
throw new Error("Title is required");
if(limit < 0)
throw new Error("Limit must be greater than 0");
if(offset < 0)
throw new Error("Offset must be greater than 0");
if(limit > 10)
throw new Error("Limit must be less than 10");
return true;
}
Now you can import validadateSearchQuery
from validation_helper.ts
and use it to well validate your query
We can edit sample_index.js
to following:
import { searchArt } from './art_helper';
import { validateSearchQuery } from './validation_helper';
import './style.css';
window.onload = ev => {
init();
}
const init = () => {
console.log('hello I am a sample index.js v5');
document.getElementById('title-search-button').addEventListener('click', handleSearchClick);
}
const handleSearchClick = (ev) => {
const title = document.getElementById('title-search-input').value;
validateSearchQuery(title, 5, 10);
searchArt({ title }).then(renderResults);
}
const renderResults = ({ artPieces }) => {
document.getElementById('search-results').innerHTML = artPieces.join('');
}
Now we can develop and build using typescript.
Posted on June 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.