Practical WEBPACK Basics: Pure Javascript frontend

steffanboodhoo

steffanboodhoo

Posted on June 20, 2022

Practical WEBPACK Basics: Pure Javascript frontend

Contents

  1. Inspiration
  2. Prerequisites
  3. Setup
  4. Webpack basics
  5. Sample App and Bundling
  6. Configurations
  7. Development
  8. Module and Loaders
  9. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

sample_index.js

window.onload = ev => {
    init();
}

const init = () => {
    console.log('hello I am a sample index.js');
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

( 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...
Enter fullscreen mode Exit fullscreen mode

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
 */
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    },
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

(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';
Enter fullscreen mode Exit fullscreen mode

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"
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    },
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
    "compilerOptions": {
        "outDir": "./dist/",
        "noImplicitAny": true,
        "module": "es6",
        "target": "es5",
        "allowJs": true,
        "moduleResolution": "node"
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

Now we can develop and build using typescript.

💖 💪 🙅 🚩
steffanboodhoo
steffanboodhoo

Posted on June 20, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related