In this article, we’re going to look at creating a build setup for handling modern JavaScript (running in web browsers) using Babel and webpack.
This is needed to ensure that our modern JavaScript code in particular is made compatible with a wider range of browsers than it might otherwise be.
JavaScript, like most web-related technologies, is evolving all the time. In the good old days, we could drop a couple of <script>
tags into a page, maybe include jQuery and a couple of plugins, then be good to go.
However, since the introduction of ES6, things have got progressively more complicated. Browser support for newer language features is often patchy, and as JavaScript apps become more ambitious, developers are starting to use modules to organize their code. In turn, this means that if you’re writing modern JavaScript today, you’ll need to introduce a build step into your process.
As you can see from the links beneath, converting down from ES6 to ES5 dramatically increases the number of browsers that we can support.
The purpose of a build system is to automate the workflow needed to get our code ready for browsers and production. This may include steps such as transpiling code to a differing standard, compiling Sass to CSS, bundling files, minifying and compressing code, and many others. To ensure these are consistently repeatable, a build system is needed to initiate the steps in a known sequence from a single command.
Prerequisites
In order to follow along, you’ll need to have both Node.js and npm installed (they come packaged together). I would recommend using a version manager such as nvm to manage your Node installation (here’s how), and if you’d like some help getting to grips with npm, then check out SitePoint’s beginner-friendly npm tutorial.
Set Up
Create a root folder somewhere on your computer and navigate into it from your terminal/command line. This will be your <ROOT>
folder.
Create a package.json
file with this:
npm init -y
Note: The -y
flag creates the file with default settings, and means you don’t need to complete any of the usual details from the command line. They can be changed in your code editor later if you wish.
Within your <ROOT>
folder, make the directories src
, src/js
, and public
. The src/js
folder will be where we’ll put our unprocessed source code, and the public
folder will be where the transpiled code will end up.
Transpiling with Babel
To get ourselves going, we’re going to install babel-cli, which provides the ability to transpile ES6 into ES5, and babel-preset-env, which allows us to target specific browser versions with the transpiled code.
npm install babel-cli babel-preset-env --save-dev
You should now see the following in your package.json
:
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-preset-env": "^1.6.1"
}
Whilst we’re in the package.json
file, let’s change the scripts
section to read like this:
"scripts": {
"build": "babel src -d public"
},
This gives us the ability to call Babel via a script, rather than directly from the terminal every time. If you’d like to find out more about npm scripts and what they can do, check out this SitePoint tutorial.
Lastly, before we can test out whether Babel is doing its thing, we need to create a .babelrc
configuration file. This is what our babel-preset-env
package will refer to for its transpile parameters.
Create a new file in your <ROOT>
directory called .babelrc
and paste the following into it:
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "safari >= 7"]
}
}
]
]
}
This will set up Babel to transpile for the last two versions of each browser, plus Safari at v7 or higher. Other options are available depending on which browsers you need to support.
With that saved, we can now test things out with a sample JavaScript file that uses ES6. For the purposes of this article, I’ve modified a copy of leftpad to use ES6 syntax in a number of places: template literals, arrow functions, const and let.
"use strict";
function leftPad(str, len, ch) {
const cache = [
"",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" "
];
str = str + "";
len = len - str.length;
if (len <= 0) return str;
if (!ch && ch !== 0) ch = " ";
ch = ch + "";
if (ch === " " && len < 10)
return () => {
cache[len] + str;
};
let pad = "";
while (true) {
if (len & 1) pad += ch;
len >>= 1;
if (len) ch += ch;
else break;
}
return `${pad}${str}`;
}
Save this as src/js/leftpad.js
and from your terminal run the following:
npm run build
If all is as intended, in your public
folder you should now find a new file called js/leftpad.js
. If you open that up, you’ll find it no longer contains any ES6 syntax and looks like this:
"use strict";
function leftPad(str, len, ch) {
var cache = ["", " ", " ", " ", " ", " ", " ", " ", " ", " "];
str = str + "";
len = len - str.length;
if (len <= 0) return str;
if (!ch && ch !== 0) ch = " ";
ch = ch + "";
if (ch === " " && len < 10) return function () {
cache[len] + str;
};
var pad = "";
while (true) {
if (len & 1) pad += ch;
len >>= 1;
if (len) ch += ch;else break;
}
return "" + pad + str;
}
Organizing Your Code with ES6 Modules
An ES6 module is a JavaScript file containing functions, objects or primitive values you wish to make available to another JavaScript file. You export
from one, and import
into the other. Any serious modern JavaScript project should consider using modules. They allow you to break your code into self-contained units and thereby make things easier to maintain; they help you avoid namespace pollution; and they help make your code more portable and reusable.
Whilst the majority of ES6 syntax is widely available in modern browsers, this isn’t yet the case with modules. At the time of writing, they’re available in Chrome, Safari (including the latest iOS version) and Edge; they’re hidden behind a flag in Firefox and Opera; and they’re not available (and likely never will be) in IE11, nor most mobile devices.
In the next section, we’ll look at how we can integrate modules into our build setup.
Export
The export keyword is what allows us to make our ES6 modules available to other files, and it gives us two options for doing so — named and default. With the named export, you can have multiple exports per module, and with a default export you only have one per module. Named exports are particularly useful where you need to export several values. For example, you may have a module containing a number of utility functions that need to be made available in various places within your apps.
So let’s turn our leftPad
file into a module, which we can then require in a second file.
Named Export
To create a named export, add the following to the bottom of the leftPad
file:
export { leftPad };
We can also remove the "use strict";
declaration from the top of the file, as modules run in strict mode by default.
Defult Export
As there’s only a single function to be exported in the leftPad
file, it might actually be a good candidate for using export default
instead:
export default function leftPad(str, len, ch) {
...
}
Again, you can remove the "use strict";
declaration from the top of the file.
Import
To make use of exported modules, we now need to import them into the file (module) we wish to use them in.
For the export default
option, the exported module can be imported under any name you wish to choose. For example, the leftPad
module can be imported like so:
import leftPad from './leftpad';
Or it could be imported as another name, like so:
import pineapple_fritter from './leftpad';
Functionally, both will work exactly the same, but it obviously makes sense to use either the same name as it was exported under, or something that makes the import understandable — perhaps where the exported name would clash with another variable name that already exists in the receiving module.
For the named export option, we must import the module using the same name as it was exported under. For our example module, we’d import it in a similar manner to that we used with the export default
syntax, but in this case, we must wrap the imported name with curly braces:
import { leftPad } from './leftpad';
The braces are mandatory with a named export, and it will fail if they aren’t used.
It’s possible to change the name of a named export on import if needed, and to do so, we need to modify our syntax a little using an import [module] as [path]
syntax. As with export
, there’s a variety of ways to do this, all of which are detailed on the MDN import page.
import { leftPad as pineapple_fritter } from './leftpad_es6';
Again, the name change is a little nonsensical, but it illustrates the point that they can be changed to anything. You should keep to good naming practices at all times, unless of course you’re writing routines for preparing fruit-based recipes.
Consuming the Exported Module
To make use of the exported leftPad
module, I’ve created the following index.js
file in the src/js
folder. Here, I loop through an array of serial numbers, and prefix them with zeros to make them into an eight-character string. Later on, we’ll make use of this and post them out to an ordered list element on an HTML page. Note that this example uses the default export syntax:
import leftPad from './leftpad';
const serNos = [6934, 23111, 23114, 1001, 211161];
const strSNos = serNos.map(sn => leftPad(sn, 8, '0'));
console.log(strSNos);
As we did earlier, run the build script from the <ROOT>
directory:
npm run build
Babel will now create an index.js
file in the public/js
directory. As with our leftPad.js
file, you should see that Babel has replaced all of the ES6 syntax and left behind only ES5 syntax. You might also notice that it has converted the ES6 module syntax to the Node-based module.exports
, meaning we can run it from the command line:
node public/js/index.js
// [ '00006934', '00023111', '00023114', '00001001', '00211161' ]
Your terminal should now log out an array of strings prefixed with zeros to make them all eight characters long. With that done, it’s time to take a look at webpack.
Introducing webpack and Integrating it with Babel
As mentioned, ES6 modules allow the JavaScript developer to break their code up into manageable chunks, but the consequence of this is that those chunks have to be served up to the requesting browser, potentially adding dozens of additional HTTP requests back to the server — something we really ought to be looking to avoid. This is where webpack comes in.
webpack is a module bundler. Its primary purpose is to process your application by tracking down all its dependencies, then package them all up into one or more bundles that can be run in the browser. However, it can be far more than that, depending upon how it’s configured.
webpack configuration is based around four key components:
- an entry point
- an output location
- loaders
- plugins
Entry: This holds the start point of your application from where webpack can identify its dependencies.
Output: This specifies where you would like the processed bundle to be saved.
Loaders: These are a way of converting one thing as an input and generating something else as an output. They can be used to extend webpack’s capabilities to handle more than just JavaScript files, and therefore convert those into valid modules as well.
Plugins: These are used to extend webpack’s capabilities into other tasks beyond bundling — such as minification, linting and optimization.
To install webpack, run the following from your <ROOT>
directory:
npm install webpack webpack-cli --save-dev
This installs webpack locally to the project, and also gives the ability to run webpack from the command line through the addition of webpack-cli
. You should now see webpack listed in your package.json
file. Whilst you’re in that file, modify the scripts section as follows, so that it now knows to use webpack instead of Babel directly:
"scripts": {
"build": "webpack --config webpack.config.js"
},
As you can see, this script is calling on a webpack.config.js
file, so let’s create that in our <ROOT>
directory with the following content:
const path = require("path");
module.exports = {
mode: 'development',
entry: "./src/js/index.js",
output: {
path: path.resolve(__dirname, "public"),
filename: "bundle.js"
}
};
This is more or less the simplest config file you need with webpack. You can see that it uses the entry and output sections described earlier (it could function with these alone), but also contains a mode: 'development'
setting.
webpack has the option of using either “development” or “production” modes. Setting mode: 'development'
optimizes for build speed and debugging, whereas mode: 'production'
optimizes for execution speed at runtime and output file size. There’s a good explanation of modes in Tobias Koppers’ article “webpack 4: mode and optimization” should you wish to read more on how they can be configured beyond the default settings.
Next, remove any files from the public/js
folder. Then rerun this:
npm run build
You’ll see that it now contains a single ./public/bundle.js
file. Open up the new file, though, and the two files we started with look rather different. This is the section of the file that contains the index.js
code. Even though it’s quite heavily modified from our original, you can still pick out its variable names:
/***/ "./src/js/index.js":
/*!*************************!*\
!*** ./src/js/index.js ***!
\*************************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _leftpad__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./leftpad */ \"./src/js/leftpad.js\");\n\n\nconst serNos = [6934, 23111, 23114, 1001, 211161];\nconst strSNos = serNos.map(sn => Object(_leftpad__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(sn, 8, '0'));\nconsole.log(strSNos);\n\n\n//# sourceURL=webpack:///./src/js/index.js?");
/***/ }),
If you run node public/js/bundle.js
from the <ROOT>
folder, you’ll see you get the same results as we had previously.
Transpiling
As mentioned earlier, loaders allow us to convert one thing into something else. In this case, we want ES6 converted into ES5. To do that, we’ll need a couple more packages:
npm install babel-loader babel-core --save-dev
To utilize them, the webpack.config.js
needs a module section adding to it after the output section, like so:
module.exports = {
entry: "./src/js/index.js",
output: {
path: path.resolve(__dirname, "public/js"),
filename: "bundle.js"
},
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules)/,
use: {
loader: "babel-loader",
options: {
presets: ["babel-preset-env"]
}
}
}
]
}
};
This uses a regex statement to identify the JavaScript files to be transpiled with the babel-loader
, whilst excluding anything in the node_modules
folder from that. Lastly, the babel-loader
is told to use the babel-preset-env
package installed earlier, to establish the transpile parameters set in the .babelrc
file.
With that done, you can rerun this:
npm run build
Then check the new public/js/bundle.js
and you’ll see that all traces of ES6 syntax have gone, but it still produces the same output as previously.
Bringing It to the Browser
Having built a functioning webpack and Babel setup, it’s time to bring what we’ve done to the browser. A small HTML file is needed, and this should be created in the <ROOT>
folder as below:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Webpack & Babel Demonstration</title>
</head>
<body>
<main>
<h1>Parts List</h1>
<ol id="part-list"></ol>
</main>
<script src="./public/js/bundle.js" charset="utf-8"></script>
</body>
</html>
There’s nothing complicated in it. The main points to note are the <ol></ol>
element, where the array of numbers will be going, and the <script></script>
element just before the closing </body>
tag, linking back to the ./public/js/bundle.js
file. So far, so good.
A little more JavaScript is needed to display the list, so let’s alter ./src/js/index.js
to make that happen:
import leftPad from './leftpad';
const serNos = [6934, 23111, 23114, 1001, 211161];
const partEl = document.getElementById('part-list');
const strList = serNos.reduce(
(acc, element) => acc += `<li>${leftPad(element, 8, '0')}</li>`, ''
);
partEl.innerHTML = strList;
Now, if you open index.html
in your browser, you should see an ordered list appear, like so:
Taking it Further
As configured above, our build system is pretty much ready to go. We can now use webpack to bundle our modules and transpile ES6 code down to ES5 with Babel.
However, it’s a bit of a niggle that, to transpile our ES6 code, we have to run npm run build
every time we make a change.
Adding a ‘watch’
To overcome the need to repeatedly run npm run build
, you can set up a 'watch'
on your files and have webpack recompile automatically every time it sees a change in one of the files in the ./src
folder. To implement that, modify the scripts
section of the package.json
file, as below:
"scripts": {
"watch": "webpack --watch",
"build": "webpack --config webpack.config.js"
},
To check that it’s working, run npm run watch
from the terminal, and you’ll see that it no longer returns to the command prompt. Now go back to src/js/index.js
and add an extra value into the serNos
array and save it. Mine now looks like this:
const serNos = [ 6934, 23111, 23114, 1001, 211161, 'abc'];
If you now check the terminal, you’ll see that it’s logged out, and that it has re-run the webpack build
task. And on going back to the browser and refreshing, you’ll see the new value added to the end of the list, having been processed with leftPad
.
Refresh the Browser Automatically
It would be really good now if we could get webpack to refresh the browser automatically every time we make a change. Let’s do that by installing an additional npm package called webpack-dev-server
. Don’t forget to Ctrl + c out of the watch
task first, though!
npm install webpack-dev-server --save-dev
With that done, let’s add a new script to the package.json
file to call the new package. The scripts
section should now contain this:
"scripts": {
"watch": "webpack --watch",
"start": "webpack --watch & webpack-dev-server --open-page 'webpack-dev-server'",
"build": "webpack --config webpack.config.js"
},
Notice the --open-page
flag added to the end of the script. This tells webpack-dev-server
to open a specific page in your default browser using its iframe mode.
Now run npm start
and you should see a new browser tab being opened at http://localhost:8080/webpack-dev-server/
with the parts list being displayed. To show that the 'watch'
is working, go to src/js/index.js
and add another new value to the end of the serNos
array. When you save your changes, you should notice them reflected almost immediately in the browser.
With this complete, the only thing remaining is for the mode in webpack.config.js
to be set to production
. Once that is set, webpack will also minify the code it outputs into ./public/js/bundle.js
. You should note that if the mode
is not set, webpack will default to using the production
config.
Conclusion
In this article, you’ve seen how to set up a build system for modern JavaScript. Initially, this used Babel from the command line to convert ES6 syntax down to ES5. You’ve then seen how to make use of ES6 modules with the export
and import
keywords, how to integrate webpack to perform a bundling task, and how to add a watch task to automate running webpack each time changes to a source file are detected. Finally you’ve seen how to install webpack-dev-server
to refresh the page automatically every time a change is made.
Should you wish to take this further, I’d suggest reading SitePoint’s deep dive into webpack and module bundling, as well as researching additional loaders and plugins that will allow webpack to handle Sass and asset compression tasks. Also look at the eslint-loader
and the plugin for Prettier too.
Happy bundling …