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 insidesrc/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:
- We want to write bleeding-edge JavaScript, including this season's must-have methods
- 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:
- If your host doesn't support HTTP2 then each file requires a new connection, all of which adds overhead
- 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
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'),
}
}
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";
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 = () => {
...
}
We could import these functions into a different module like this:
import returnRandomEmoji, { returnRandomNumber, returnRandomWord } from './tools/randoms.js';
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);
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
...
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");
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
Hang on, what's npx
when it's at its gran's?
First, a quick reminder of what our old pal NPM does:
- Installs packages dragged from the internet
- Runs specific commands we've added to
package.json
such asstart
orprepare
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:
- Create a duplicate of
webpack.config.mjs
and tweak it a bit for production - 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
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
...
}
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',
});
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
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',
});
Test that configuration out at the terminal by typing:
npx webpack --config webpack.prod.mjs
Now have another look at dist/js/main.js
. It should look like this:
(()=>{"use strict";console.log("Hello worm")})();
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 thedist
directory -
start
- runs three different of the commands above at the same time -serve
,sass-dev
andwatch-images
-
prepare
- populates thedist
directory with production-ready files -
watch-images
- watches theimg
directory for changes, then invokes ourimage-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\"",
...
}
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",
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\""
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
(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"
}
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>
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
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");
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
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
(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>`);
};
It's a function called addEmoji
which takes two parameters:
- An emoji as a string (or just, you know, a string of text)
- 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");
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');
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
-
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. β©
-
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. β©
-
Well, I did add the configuration but it didn't work first time and I got bored β©
Top comments (0)