DEV Community

Cover image for Chapter 5: JavaScript
Ross Angus
Ross Angus

Posted on

1

Chapter 5: JavaScript

Cover image by Audy of Course

Recap

Last time, we finished optimising our images:

  • Used the fs module from Node to elegantly handle nested directories inside src/img
  • Learned about short-circuit evaluation in JavaScript
  • Stole a utility function from Param Harrison to get a list of files and folders in Node
  • We looked at how we might split up a long, complicated script into modules
  • We learned the shorthand which can be used in JavaScript if the name and property are the same

Now we're going to write some more JavaScript to process our JavaScript.

JavaScript

We want to do a couple of things with our JavaScript:

  1. We want to write bleeding-edge JavaScript, including this season's must-have methods
  2. We want it to run fast on a decent proportion of the web

We're going to be writing our JavaScript using ES6 modules which allows us to split them into many different units. And while support for ES6 is excellent, downloading all of those files individually isn't great for a couple of reasons:

  1. If your host doesn't support HTTP2 then each file requires a new connection, all of which adds overhead
  2. The end user needs to download all of your comments and whitespace too, which they probably aren't interested in

Guess what? There's A Package For Thatβ„’!

Introducing Webpack

Webpack was invented for one reason and one reason only: to take a bunch of JavaScript modules and squash them into one package. Which is exactly what we're going to use it for.


Historical aside

Remember when I talked about how Ryan Dahl made Node.js and intended it to have one purpose and that other people would build on top of it? What this originally led to was what are called "task runners" - early examples included Grunt (which wrote files to disk) and Gulp (which ran files from memory). Gulp's still pretty popular! (but nowhere as popular as Webpack)

Task runners are complicated to configure and as we've learned, we can mostly get by without them these days.

However, when Webpack came along, people started using it as a task runner. And it works. So everything we've done so far could probably be achieved using Webpack. But I don't think we need to do that and I'm not alone.

But don't take my word for it. By all means, check out the sass-loader for Webpack. It won't be covered in this guide. If anyone has done this successfully and come back here to tell me why I'm wrong, here's your medal:

πŸŽ–


Installing Webpack

Let's use the street-slang NPM version. In a terminal, type:

npm i -D webpack webpack-cli
Enter fullscreen mode Exit fullscreen mode

Note how we're installing both Webpack and webpack-cli on the same line. Nice little trick there.

What's this webpack-cli business? Don't worry about it. It's just Webpack's wee pal. Won't go anywhere without it.

This should plonk a reference to Webpack and webpack-cli in our package.json. You know the score by now.

Configuring Webpack

Remember how we had to create .gitignore? Sometimes packages are so special that they don't want to add their configuration into package.json and demand their own special configuration files. Webpack's like that1.

Bespoke configuration file

Webpack's configuration file is called webpack.config.mjs and it should sit in the root of your Node application - as a sibling to package.json and the rest of the gang.

Here's what it should look like:

import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  mode: 'development',
  entry: './src/js/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist/js'),
  }
}
Enter fullscreen mode Exit fullscreen mode

What's an MJS file?

This configuration file example is pretty far from the Webpack documentation. Even the file extension is different. Why is this?

Remember when we enabled JavaScript modules in Node? This led to Webpack expecting ES Module syntax. This is not what the documentation for Webpack is expecting.

The file extension mjs is short for "Modular JavaScript" and reminds Node how they should be treated.


The first line of webpack.config.mjs imports the path function from Node and renames it "path". Imaginative. The path function lets us view and manipulate the paths of the files we want to change. Earlier, we used the fs module in Node to change files on the hard drive, when we wanted a list of images. But this is just a configuration file so it's not directly changing files.

We import another in-build Node function fileURLToPath.


Different kinds of imports in ES6

When a JavaScript module exports just a single function or one function is marked as the default, we can call it like this:

import myFunction from "./tools/my-function.js";
Enter fullscreen mode Exit fullscreen mode

But sometimes you want to have a few different functions in the same file. Let's say a file looks like this:

export returnRandomNumber = () => {
  ...
};

export returnRandomWord = () => {
  ...
}

//  Hello ↓
export default returnRandomEmoji = () => {
  ...
}
Enter fullscreen mode Exit fullscreen mode

We could import these functions into a different module like this:

import returnRandomEmoji, { returnRandomNumber, returnRandomWord } from './tools/randoms.js';
Enter fullscreen mode Exit fullscreen mode

Because returnRandomEmoji is the default export from randoms.js, it gets to escape from the curly braces. The same cannot be said for returnRandomNumber and returnRandomWord, who are still exported, but just not as the default.



When we're writing JavaScript in this way, each file is assumed to be a module. This means the JavaScript engine makes certain assumptions about what it exports - whether it does so or not. The Webpack configuration file does nothing except exports some JSON which represents the configuration information. That's what the module.exports object means. Why isn't it just a JSON file? Probably because it needs to call on various Node functions to return path information.

The entry node points to a new file in a new directory within our application. We'll create something in there in a moment.

The output node points to another file - this will be generated by Webpack and it's what we should point our HTML file at. We've set it to create the file main.js inside the dist/js directory.

The second node inside the output node - path - uses the path component from Node. The resolve function just squashes together two strings. The second of which is our good old dist directory.

If we were using Webpack in the conventional way (that is, using ES5 syntax), we could access an environmental variable called __dirname. This returns the full path to the root of our application. However, in ES6, we need to derive this variable using the lines:

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Enter fullscreen mode Exit fullscreen mode

If it sounds like I don't fully understand this, that's because I don't fully understand this. Basically, by trying to force Webpack to use ES6 we've left the documentation behind and are in (mostly) uncharted territory. Perhaps there's a reason why most people don't do it this way.

With all this in mind, think of this file as just some fancy JSON.

Wait, if it's fancy JSON, how come each node ends with a comma?

Remember when I told you how strict JSON was about ending every node with a comma, apart from the last one? This doesn't follow that rule because it's a JavaScript object which we're treating like JSON, rather than real JSON.

If it's any consolation, I'm sorry for the confusion.

Setting up our directories

We need to set up directories for our JavaScript. Inside your src and dist directories, add a new js directory to each. Have it as a sibling of img, so they look like this:

πŸ—€ dist
  πŸ—€ css
  πŸ—€ img
  πŸ—€ js
πŸ—€ src
  πŸ—€ img
  πŸ—€ js
  πŸ—€ scss
...
Enter fullscreen mode Exit fullscreen mode

Add your entry point

Create a new JavaScript file inside src/js called index.js. Let's keep it simple for now:

console.log("Hello worm");
Enter fullscreen mode Exit fullscreen mode

This is the file which the Webpack configuration file is pointed at as an input.

Loading the configuration into Webpack

Just creating the file isn't enough. We need to point Webpack at it, much like you might bring a horse to water.

Let's run Webpack from the terminal. Type (or copy-&-paste) the following into your terminal.

npx webpack --config webpack.config.mjs
Enter fullscreen mode Exit fullscreen mode

Hang on, what's npx when it's at its gran's?

First, a quick reminder of what our old pal NPM does:

  1. Installs packages dragged from the internet
  2. Runs specific commands we've added to package.json such as start or prepare

What if we wanted to pull out a specific bit of functionality we know is hidden inside node_modules but didn't need to write a command for it? This is one of the two things npx does2.

The line we've just typed into the terminal asks npx to find our webpack application, then passes it the --config flag and also the location of our webpack-config.mjs file.

After you've run this command, you should see a new file appear in dist/js/ called main.js. Have a look!

Our single line of JavaScript has somehow turned into 49 lines where the developer was paid by the underscore. Don't worry - we won't be writing code like this. If you read some of the comments, you'll notice Comrade Webpack telling us that we're seeing this mess because we triggered a development build of the JavaScript, rather than a production build.

Adding in a production build

We've got two choices here:

  1. Create a duplicate of webpack.config.mjs and tweak it a bit for production
  2. Install another plugin

I know option 2 doesn't sound appealing but we all took a sacred oath to uphold the principles of DRY and we cannot break that pact.

(More serious justification: because the production and development configurations share a lot of data, we want to share that common data between them, so that if we update it in one place, it impacts both environments. If we didn't do this, we might run the risk of an issue appearing on the live site which doesn't exist on the development site, which would take ages to debug.)


Development sites?

The vast majority of websites you interact with have shadowy clones of themselves which you're not allowed to see. These are used to preview and test changes to that site to various stakeholders before the general public clicks all over them. They have a bunch of different names which reflect their purposes. Here's some of my favourites:

  • Development (or dev)
  • Quality-assurance (or qa)
  • Staging (or stag)
  • Pre-production (or pre-prod)

Finally, finished code is published to:

  • Production (or prod)
  • Live (same as prod)


Installing webpack-merge

webpack-merge takes two different webpack configuration files and squashes them together. This lets our development and production configuration inherit from a third configuration we're going to call webpack.common.mjs. First, let's install the package. In a terminal, type:

npm i -D webpack-merge
Enter fullscreen mode Exit fullscreen mode

Splitting up our configuration

Rename webpack.configuration.mjs to webpack.common.mjs. Then take a copy of it and call it webpack.dev.mjs. All these files should sit in the root of the Node application (they should be siblings to package.json).

webpack.common.mjs just needs a single line removed. The line which sets the mode to development:

export default {
  mode: 'development', // ← Remove this line
  ...
}
Enter fullscreen mode Exit fullscreen mode

The new webpack.dev.mjs file should look like this:

import { merge } from 'webpack-merge';
import common from './webpack.common.mjs';

export default merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
});
Enter fullscreen mode Exit fullscreen mode

At the start of our new configuration file, we import the merge function from webpack-merge. Then we pull in our other file, which we're calling common.

Then we take the imported common config and squash it together with some new JSON, using the merge() function. This returns some JSON which is exported as if nothing happened.

The new JSON object sets the mode to development and uses the inline-source-map devtool. That's all we need to know for now!

Remind me: why do we need a development and production mode configuration?

Just like with SASS, while we're writing JavaScript we need to know which specific file any errors might be coming from. If we let Webpack compile all our code into a single file just like on live, we couldn't trace it back to a specific source file. The browser would always report the error on "line 1" of the JavaScript file (because Webpack will squash all our code into a single line).

Once the mode is set to development the browser will magically know the specific source file the error has originated from. So much easier.

The production configuration strips all of that out, giving us just the leanest possible JavaScript.

Testing it out

To test out our new configuration, run:

npx webpack --config webpack.dev.mjs
Enter fullscreen mode Exit fullscreen mode

Check your dist/js/main.js file and it should look the same as it did before. All this work and nothing to show for it.

Adding a production configuration

Let's create a new configuration for production. Take a copy of webpack.dev.mjs and call it webpack.prod.mjs. It should look like this:

import { merge } from 'webpack-merge';
import common from './webpack.common.mjs';

export default merge(common, {
  mode: 'production',
});
Enter fullscreen mode Exit fullscreen mode

Test that configuration out at the terminal by typing:

npx webpack --config webpack.prod.mjs
Enter fullscreen mode Exit fullscreen mode

Now have another look at dist/js/main.js. It should look like this:

(()=>{"use strict";console.log("Hello worm")})();
Enter fullscreen mode Exit fullscreen mode

Well, at least it's small now. What's with the syntax? Was the developer paid by the parentheses?

Immediately Invoked Function Expressions

You know how everyone's frightened of (in ascending order of threat) spiders, nuclear war and polluting the global scope? Immediately Invoked Function Expressions (or IIFEs, to their friends) were our best method of encapsulating variables in JavaScript before ES5 and the like.

Because variables which are declared inside of functions are only in scope within that function, IIFEs are a method of declaring an anonymous function which immediately runs (or is invoked). Webpack uses a modern arrow function variation but you can use older methods too. Essentially, the first set of parenthesis encapsulates the code and the second set runs it.

"use strict"

This invokes a slightly more strict version of JavaScript, at least compared to how things used to be in The Bad Old Days. Because Webpack is generating backward looking JavaScript, this tells the JavaScript parsing engines in browsers to step up their game.

Adding in our new commands to package.json

Remember our old pal package.json? Let's see how they are doing. We currently have a load of commands:

  • sass-dev - converts SASS files to CSS during development
  • sass-prod - converts SASS files to CSS for production (not currently used)
  • serve - starts up a web server and points it at the dist directory
  • start - runs three different of the commands above at the same time - serve, sass-dev and watch-images
  • prepare - populates the dist directory with production-ready files
  • watch-images - watches the img directory for changes, then invokes our image-compress script

Take a moment to appreciate how much you've learned.


OK, the moment has passed.

Let's add our production webpack command to our prepare task. It should now read:

"scripts": {
  "prepare": "concurrently \"npm run sass-prod\" \"node tools/compress-all-images.js\" \"npx webpack --config webpack.prod.mjs\"",
  ...
}
Enter fullscreen mode Exit fullscreen mode

The prepare command should be used to create production-ready files.

While we're at it, let's add a new watch task to pick up on any JavaScript changes. After the watch-images command, add:

"watch-js": "onchange \"src/js\" -- npx webpack --config webpack.dev.mjs",
Enter fullscreen mode Exit fullscreen mode

The watch-js command will produce files which help us with development. Now let's call watch-js as part of the start command, so it reads:

"start": "concurrently \"npm run serve\" \"npm run sass-dev\" \"npm run watch-images\" \"npm run watch-js\""
Enter fullscreen mode Exit fullscreen mode

Testing out the production JavaScript

Let's test this all out! Delete the contents of the dist/js, dist/css and dist/img directories (leave dist/index.html!), then in the terminal type:

npm install
Enter fullscreen mode Exit fullscreen mode

(remember, this is the same as running npm run prepare)

Now check those same directories again. You should see the production-ready CSS, JavaScript and images.

The scripts node of your package.json should look like this:

"scripts": {
  "sass-dev": "sass --watch --update --style=expanded src/scss:dist/css",
  "sass-prod": "sass --no-source-map --style=compressed src/scss:dist/css",
  "serve": "browser-sync start --server \"dist\" --files \"dist\"",
  "start": "concurrently \"npm run serve\" \"npm run sass-dev\" \"npm run watch-images\" \"npm run watch-js\"",
  "prepare": "concurrently \"npm run sass-prod\" \"node tools/compress-all-images.js\" \"npx webpack --config webpack.prod.mjs\"",
  "watch-images": "onchange \"src/img\" -- node tools/image-compress.js {{file}} {{event}}",
  "watch-js": "onchange \"src/js\" -- npx webpack --config webpack.dev.mjs"
}
Enter fullscreen mode Exit fullscreen mode

Testing out the development JavaScript

In dist/index.html, include a script tag to link to your JavaScript file:

<script src="/js/main.js"></script>
Enter fullscreen mode Exit fullscreen mode

This should appear inside your body tag but after all the rest of the HTML. This is so the page renders first and then the JavaScript has access to everything in the DOM.

Start up your local server with:

npm start
Enter fullscreen mode Exit fullscreen mode

You should (eventually) see your page. Right-click and inspect an element to open the developer tools. Switch to the Console tab at the top of the developer tools and you should see the text Hello worm appear.

Does it change, when we update the JavaScript? Edit src/js/index.js so that it reads:

console.log("Hello world");
Enter fullscreen mode Exit fullscreen mode

Check the console in your browser. It should now say "Hello world" instead of "Hello worm".

JavaScript Modules

A quick reminder of how our scss directory looks:

πŸ—€ src
  ...
  πŸ—€ scss
    _fonts.scss
    main.scss
Enter fullscreen mode Exit fullscreen mode

How this works is that SASS ignores any files which start with an underscore. So we use main.scss to import all of the scss files in any directory structure we want. That way, we can keep our sass modular and still export just one file to the production environment.

We want to take a similar approach with JavaScript. Remember when we split image-compress.js into different functions, then imported and exported them? We want to do this with our production JavaScript too (reminder: all the JavaScript we've currently written except console.log("Hello world"); won't end up on the live site).

Rather than using underscores, we don't need to do anything to exclude individual JavaScript files from our bundle. Webpack is opt-in rather than opt-out. In fact the only JavaScript file Webpack will look at is src/js/index.js, because that's what we defined in webpack.common.mjs.

All we need to produce modular JavaScript is to write a module and import it into index.js. So let's do that now.

Directory structure

Inside your src/js directory, create a new folder called modules. Inside that, create another directory called add-emoji. Inside that, add a new file called add-emoji.js. Your directory structure should look like this:

πŸ—€ src
  ...
  πŸ—€ js
    πŸ—€ modules
      πŸ—€ add-emoji
        add-emoji.js
Enter fullscreen mode Exit fullscreen mode

(VS Code might try and help you out by flattening this out visually)

Personally, I'd advice kebab case for directories and file names, and camel case for function names.

Add Emoji

This is our first client-side (i.e. this runs on the "client" or web browser) module:

export default addEmoji = (emoji = '😊', selector = 'body') => {
  const element = document.querySelector(selector);
  element && element.insertAdjacentHTML('afterbegin', `<big>${emoji}</big>`);
};
Enter fullscreen mode Exit fullscreen mode

It's a function called addEmoji which takes two parameters:

  1. An emoji as a string (or just, you know, a string of text)
  2. A CSS selector of a tag where you want to insert the emoji just after the opening tag (this is also a string)

If the function is called without any arguments, it falls back to the defaults of rendering a 😊 emoji just after the start of the body tag. The emoji itself is rendered inside a big tag because no-one has used this tag for over five years now and I'm worried its internet licence might expire.

Note that I'm using short-circuit evaluation on the second line inside the function - this will check if the element exists or not. So even if the function is called with an invalid CSS selector, no error will be triggered.

But of course, a module will do nothing until it's called.

Calling the module

Open up src/js/index.js. It should look like this:

console.log("Hello world");
Enter fullscreen mode Exit fullscreen mode

Get rid of that line and instead, import our new module, then call our new function three times:

import addEmoji from "./modules/add-emoji/add-emoji.js";

// Will add `😊` after the opening `body` tag
addEmoji();
// Will add `πŸͺ±` inside the first `p` tag on the page
addEmoji('πŸͺ±', 'p');
// Will not find a `section` tag on the page and silently fail
addEmoji('😈', 'section');
Enter fullscreen mode Exit fullscreen mode

To add the file extension or not

It's possible that VS Code tried to finish that import statement above for you, like an eager fan in the front row of a concert (you are the singer in this analogy). And in doing so, it dropped the .js. This is an error. Because we're importing a local file, we need to specify this file extension, no matter what VS Code thinks.

You might encounter module imports elsewhere which don't need to specify a file extension. These rely on specific configurations to handle fallback cases such as this to work. I want to keep the Webpack configuration as small as possible, so I've not added them3.

Testing this out

Your page should have refreshed after each save. You should see a new smiley face above Hello Worm, a new worm emoji inside that paragraph and no devil face on the page (unless you added a section tag, of course).

Most importantly, you shouldn't see anything in the console of the web browser (apart from complaining that you're missing a favicon) - no errors, no Hello world.

Congratulations! You're writing client-side code in a modern way!

Let's review what we learned:

  • Webpack does for JavaScript what SASS does for CSS (and SASS)
  • How to configure Webpack for development
  • Two different types of module imports in JavaScript
  • Why we might use NPX as well as NPM
  • How to configure Webpack for production
  • How to keep these two configuration files DRY
  • What IIFEs are and why we use them
  • Writing client-side JavaScript modules and the correct way to import them

View Chapter 5 code snapshot on GitHub

Quiz


  1. Strictly speaking, Webpack wants to work with zero configuration but it's rare that the out-of-the-box experience is exactly what we want. β†©

  2. The other is to run a bit of functionality, but instead of it being pulled from your hard drive the internet acts as your hard drive. Woah. β†©

  3. Well, I did add the configuration but it didn't work first time and I got bored β†©

Top comments (0)