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:
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…
@babel/polyfill
@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 auseBuiltIns
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.