Modern JavaScript: Webpack and Babel

Introduction

This post will explain how to set-up and configure the various tooling necessary in order to be able to write cross-compatible modern (ES2015+) JavaScript code.

Note: if you’re unsure of what ‘modern’ JavaScript looks like, then I’ll refer you to these compatibility tables.

The tools we’ll be using:

  • Babel: transpiler of modern JS into ES5 compatible code.
  • Webpack: a js module bundler.

Note: webpack is actually capable of transforming, bundling, packaging just about anything (as we’ll see shortly).

To clarify, you don’t need ‘webpack’, as babel handles transpiling modern JS code into ES5 compatible code, but it makes sense to use webpack still as a way to help with the performance of your client-side services.

With that in mind the configuration I’ll be describing will be using webpack primarily, and under that I’ll be using webpack ‘loaders’ to utilise the babel transpiler.

If you weren’t using webpack, the configuration would be different as you’d be configuring babel directly instead of webpack.

Example Project

We’re going to create a very basic project. It’s so basic, it doesn’t really do anything. It’s the bare minimum required in order to demonstrate the setup and configuration of webpack and babel (I purposely did this because learning new tech can be confusing enough without needing to understand a real-world application at the same time).

One thing you’ll need upfront though, is Node and NPM installed, as we’ll be installing webpack and babel from existing NPM packages.

Let’s begin by creating our project directory:

mkdir modern-js && cd modern-js

Note: I will be working exclusively from the terminal.

Now create an empty package.json file:

npm init -y

Next, we’ll install all the relevant packages we’ll be needing…

npm install --save-dev webpack \
                       webpack-cli \
                       webpack-dev-server \
                       @babel/core \
                       @babel/preset-env \
                       "babel-loader@^8.0.0-beta" \
                       style-loader \
                       css-loader \
                       sass-loader \
                       node-sass \
                       eslint@4.x babel-eslint@8

npm install --save @babel/polyfill

Note: the dev dependencies need to be installed in the order they’re specified above, otherwise npm will complain/fail.

Your package.json should now have the following content:

{
  "name": "modern-js",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "babel-eslint": "^8.2.6",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "eslint": "^4.19.1",
    "node-sass": "^4.9.3",
    "sass-loader": "^7.1.0",
    "style-loader": "^0.23.0",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9"
  },
  "dependencies": {
    "@babel/polyfill": "^7.0.0"
  }
}

There are two important Babel related packages to pay attention to…

  1. @babel/polyfill
  2. @babel/present-env

The @babel/polyfill dependency will help emulate a full ES2015+ environment, and this means you can use new built-ins like Promise or WeakMap, static methods like Array.from or Object.assign, instance methods like Array.prototype.includes (amongst others, see the documentation for more information).

The @babel/preset-env helps manage the browser environment for you, so it’ll handle determining what additional scripts/polyfills you need. By default it’ll setup everything to support ES5 environments, but you can configure it for very specific browsers if you don’t need to worry about older browsers.

Note: @babel/polyfill also provides a useBuiltIns flag which allows Babel to selectively polyfill built-in features that were introduced as part of ES6+. Because it filters polyfills to include only the ones required by the environment, we mitigate the cost of shipping with babel-polyfill in its entirety.

Now let’s create all the files we need to build out our example application:

mkdir src dist
touch .eslintrc src/index.js src/component.js src/styles.scss dist/index.html

This should result in the following tree structure…

tree -I node_modules

.
├── .eslintrc
├── dist
│   └── index.html
├── src
│   ├── component.js
│   ├── index.js
│   └── styles.scss

We can see we have one Sass file and two JavaScript files, as well as a single HTML page that will load our scripts/css.

Let’s now look at the contents of each of these files…

.eslintrc

{
  "parser": "babel-eslint",
  "globals": {
    "console": true,
    "document": true,
    "window": true
  },
  "rules": {
    'brace-style': [2, '1tbs', {'allowSingleLine': true}],
    'camelcase': 2,
    'comma-spacing': 2,
    'comma-style': 2,
    'curly': 2,
    'eol-last': 2,
    'indent': [2, 2],
    'key-spacing': 2,
    'new-cap': 2,
    'new-parens': 2,
    'no-lonely-if': 2,
    'no-multi-spaces': 2,
    'no-multiple-empty-lines': [2, {'max': 2}],
    'func-call-spacing': 2,
    'no-trailing-spaces': 2,
    'quotes': [2, 'single', {'allowTemplateLiterals': true}],
    'semi': 2,
    'semi-spacing': 2,
    'space-before-blocks': 2,
    'space-in-parens': 2,
    'space-infix-ops': 2,
    'space-unary-ops': 2,
    'array-callback-return': 2,
    'block-scoped-var': 2,
    'consistent-return': 2,
    'eqeqeq': 2,
    'guard-for-in': 2,
    'no-array-constructor': 2,
    'no-caller': 2,
    'no-cond-assign': 2,
    'no-const-assign': 2,
    'no-control-regex': 2,
    'no-delete-var': 2,
    'no-dupe-args': 2,
    'no-dupe-class-members': 2,
    'no-dupe-keys': 2,
    'no-duplicate-case': 2,
    'no-empty-character-class': 2,
    'no-empty-pattern': 2,
    'no-eval': 2,
    'no-ex-assign': 2,
    'no-extend-native': 2,
    'no-extra-bind': 2,
    'no-fallthrough': 2,
    'no-func-assign': 2,
    'no-implied-eval': 2,
    'no-invalid-regexp': 2,
    'no-iterator': 2,
    'no-lone-blocks': 2,
    'no-loop-func': 2,
    'no-mixed-operators': [2, {
      groups: [
        ['&', '|', '^', '~', '<<', '>>', '>>>'],
        ['==', '!=', '===', '!==', '>', '>=', '<', '<='],
        ['&&', '||'],
        ['in', 'instanceof']
      ],
      allowSamePrecedence: false
    }],
    'no-multi-str': 2,
    'no-native-reassign': 2,
    'no-unneeded-ternary': 2,
    'no-unsafe-negation': 2,
    'no-new-func': 2,
    'no-new-object': 2,
    'no-new-symbol': 2,
    'no-new-wrappers': 2,
    'no-obj-calls': 2,
    'no-octal': 2,
    'no-octal-escape': 2,
    'no-redeclare': 2,
    'no-regex-spaces': 2,
    'no-script-url': 2,
    'no-self-assign': 2,
    'no-self-compare': 2,
    'no-sequences': 2,
    'no-shadow-restricted-names': 2,
    'no-shadow': 2,
    'no-sparse-arrays': 2,
    'no-template-curly-in-string': 2,
    'no-this-before-super': 2,
    'no-throw-literal': 2,
    'no-undef': 2,
    'no-unexpected-multiline': 2,
    'no-unreachable': 2,
    'no-unused-expressions': [2, {
      'allowShortCircuit': true,
      'allowTernary': true
    }],
    'no-unused-vars': 2,
    'no-use-before-define': [2, 'nofunc'],
    'no-useless-computed-key': 2,
    'no-useless-concat': 2,
    'no-useless-constructor': 2,
    'no-useless-escape': 2,
    'no-useless-rename': 2,
    'no-with': 2,
    'radix': 2,
    'require-yield': 2,
    'use-isnan': 2,
    'valid-typeof': 2,
    'wrap-iife': [2, 'any']
  }
}

You don’t have to worry too much about the contents of this file, as it’s just the configuration I’m using to define what is ‘good’ JavaScript syntax.

In other words, if your code editor is configured properly, then any JS code you write that violates any of these ES linter values, will be flagged as an error.

dist/index.html

<!doctype html>
<html>
  <head>
    <title>Hello Webpack</title>
  </head>
  <body>
    <script src="bundle.js"></script>
  </body>
</html>

This is simple enough, a HTML page that loads a bundle.js script (which currently doesn’t exist, and will be generated by webpack, via babel).

src/component.js

const c = ['x', 'y', 'z'];

export default c;

A simple script that defines an Array of items and then exports them (using modern JS module syntax, familiar to people who may have written Node applications before).

src/index.js

/*eslint no-undef: "error"*/
/*eslint-env browser*/

import '@babel/polyfill';
import './styles.scss';
import c from './component.js';

console.log(c);

let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a);
console.log(b);

const root = document.createElement('div');
root.innerHTML = `<p>Hello Webpack!</p>`;
document.body.appendChild(root);

A simple script that imports from our component.js and then logs it (to prove the import code works), it then demonstrates let variables and another modern JS feature known as ‘destructuring’ before finally creating a HTML <div> element and populating it with some text and inserting it into the DOM of the HTML page.

At the top of the script you’ll notice some code comments. These are used to tell our ES linter package what context this script is running in, and so global references such as document or console won’t trigger a linting error as we’ve told the linter that the context the script will run is the browser environment, where those globals are expected to exist. The code comment for eslint-env could be replaced by adding individual references into the .eslintrc file (and I have done that, take a look at the globals field in the file contents shown earlier), but I prefer the code comment as it can be a much clearer indicator of the expectations of the file’s scope.

You’ll also notice that we import the @babel/polyfill at the top of the file. This module must always be the first import in the file.

Lastly, you’ll notice we also import a Sass file, which admittedly is a bit strange considering we’re dealing with a JS file, but we’ll dig into this a little bit more later on and why we do that.

src/styles.scss

$color: blue;

body {
  background-color: $color;
}

This is a simple Sass file that demonstrates how to use a ‘variable’ to generate dynamic CSS output (in this case, the body element should have a blue background).

Webpack Configuration

OK, at this point we have a set of JS files that can’t be used in some browsers due to the fact that they use features not supported by most web browsers. So we want to compile this code down into something that is understandable to most browsers (i.e. ES5 standardized code).

To do that we’ll be using Babel (as our transpiler) and Webpack as our module bundler. Webpack will take the separate JS files and combine them into a single bundle.js file, which our HTML will attempt to load.

So let’s create a webpack.config.js file:

touch webpack.config.js

We’ll then add the following contents to that file:

/*eslint-env node*/

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        }
      },
      {
        test: /\.scss$/,
        use: [
          {loader: 'style-loader'},
          {loader: 'css-loader'},
          {loader: 'sass-loader'}
        ]
      }
    ]
  }
};

You’ll notice a similar code comment at the top of the file, which again is for the benefit of our ES linter. In this case it won’t complain when it sees certain global references such as require or module (as we’ve told the linter that the context the script will be run in is the Node environment, where those globals are expected to exist).

We see the entry field of the configuration is telling webpack that the main application JavaScript file is going to be found at ./src/index.js. Webpack will look at this file and then traverse back up its dependency tree looking for imports and it will combine each of those separate files into one single file.

The file that is generated (and where) is determined by the output field. We can see we want the file to be called bundle.js and we want the file to be saved to the ./dist directory (this is indicated by the path field).

The publicPath field is a bit different, in that it tells the webpack-dev-server package where to find the bundle.js file that the HTML is attempting to load. What won’t be clear yet is the fact that when running our code locally (in dev) we’ll be using webpack-dev-server because it allows us to utilise a web server for running our code as well as ‘hot reloading’ (which means, if we’re dealing with a complex single-page application with lots of nested state, that a change in code doesn’t cause us to lose the state the page is in).

For production we’ll statically generate our final bundle file using the standard webpack command, and so you’ll see shortly that we need to update our package.json to include two npm run ... commands that let us use either webpack-dev-server or webpack depending on where our code needs to run.

The module field tells webpack, that for every file it finds, before adding it to the final bundle.js, to run it through a ‘loader’ script for additional processing. In this case, all .js files are passed through babel and so their modern JS code is transpiled into ES5 code first before being added to bundle.js.

Finally, we look for any Sass .scss files and tell webpack to pass those files through multiple ‘loaders’:

  • sass-loader: transforms Sass into CSS.
  • css-loader: parses the CSS into JavaScript and resolves any dependencies.
  • style-loader: outputs our CSS into a <style> tag in the document.

Loaders are executed in a nested fashion, meaning the above order of loaders would evaluate to something like:

styleLoader(cssLoader(sassLoader("source")))

Where the source file is passed into the Sass loader, and so transforming the Sass into CSS. That CSS is then passed into the CSS loader, which allows it to be parsed by JavaScript. Finally, the Style loader places our CSS into a <style> tag within our HTML page.

Package.json Update

As mentioned earlier, we want to modify the package.json so that we have two npm run ... commands for letting us run our code in development mode (i.e. locally) or compile our code ready for use in production.

The following snippet shows the changes needed to be made:

{
  ...

  "scripts": {
    "build": "webpack --mode=production",
    "dev": "webpack-dev-server --mode=development --config webpack.config.js",

    ...
  },
  
  ...
}

Now we can run either npm run dev (for local dev) or npm run build (to compile our bundle for production).

You’ll notice that we pass a specific --mode flag to both webpack and webpack-dev-server, and this indicates to both tools what to do to the files it’s configured to interact with.

In the case of --mode=production the webpack tool will make additional modifications that means the bundle.js output is as efficient as possible (such as minifiying and obfuscating the code).

Whereas --mode=development will allow for the generation of webpack source map files (to aid you in debugging).

Conclusion

This should be the basics covered of how to use babel with webpack, and hopefully is enough to help you kickstart your exploration of new JavaScript features.