Babel & Webpack Fundamentals

How the front-end code we write ends up being executed when visitors use our web-app

Apr 25, 2021


This post isn't directly about website speed. Instead, it touches upon the fundamentals of Babel, Webpack and all things related. This is to help us understand how the front-end JavaScript code that we write ends up being executed when visitors use our web-app. Better understanding of this shall, hopefully help write better (and faster) web-apps.

Introduction

Before talking about Babel or Webpack, let's quickly look at basics of some important parts of the JavaScript ecosystem in this context:

Node

Node is JavaScript runtime built on top of Chrome's V8 JavaScript engine. With Node, we can execute JavaScript on the server-side to serve client requests. So, the Javascript engine used by Chrome has been ported to Node to enable execution of JavaScript on the server-side. Node's capability to execute JavaScript has enabled SSR (Server-side Rendering) of web-apps that are developed to run within browsers.

The V8 engine underneath the Node server is regularly updated with newer Node versions. This ensures that new JavaScript features also become available on newer Node versions. However, there are always some differences in the JavaScript that can natively run on the browser vs that on the Node server. We shall delve into this in more detail in the later sections.

CommonJS

Historically, CommonJS was a standard derived to bring modules into the JavaScript ecosystem. This had become necessary as JavaScript was starting to be used to write large web-apps outside the browser. What we commonly call CommonJS today is Node's version of that CommonJS standard. This executes natively within Node but not on browsers. The require('libname') calls within our code is a CommonJS feature. We use these for server-side JavaScript because Node (atleast upto v12) doesn't natively support ES6 imports out-of-the-box.

So, while Node leverages browser's V8 JavaScript engine, there are always differences in the JavaScript capabilities supported by Node (server-side) and browsers (client-side).

ES6 / ES2015

ES6 (or ES 2015) is a JavaScript specification that brought in various JavaScript features like clases, arrow functions, import statements and promises. As of today, majority of popular browsers support ES6 JavaScript features. This means that most browsers can natively execute ES6 JavaScript code. However, few browsers like IE, UC Browser & Opera Mini do not support ES6 (as of April 2020). Also, Node doesn't support every ES6 feature out-of-the-box. As a result, we require polyfills to run ES6+ JavaScript anywhere.

While ES6 was a major JavaScript update & was released in 2015, there has been an ES standards release every year post 2015. Each of these vary in browser support.

Babel

Babel is a JavaScript compiler that converts ES6+ JavaScript code into backwards compatible JavaScript. This enables modern JavaScript code to run on any browser. Babel allows us to specify target environment and it creates compatible JavaScript version for that target environment. It can also transform framework specific JavaScript (like React JSX) into target browser-compatible JavaScript.

To understand Babel further, let's see it in action:

  • Let's create a short & simple JavaScript file (let's call it test.js) with an arrow function (an ES6 feature)
  • [1, 2, 3, 4, 5].map(n => console.log(n))
    
  • Install babel in your local folder where test.js is just created.
  • npm install --save-dev @babel/core @babel/cli
  • Let's run babel for our test.js
  • npx babel test.js
  • Following is the output for running babel against test.js (same as the source).
  • [1, 2, 3, 4, 5].map(n => console.log(n));
The reason Babel output above isn't backward compatible JavaScript is because Babel doesn't transpile on it's own. It requires us to specify the plugins we want to use to do so. Babel Plugins & Presets Babel plugins are JavaScript functions that can syntax check (to parse) or transform our code. Babel configuration allows us to chain multiple plugins to do so. Often, enabling a plugin also enables a few other plugins. Or, we can use presets which are nothing but a group of plugins. Let's setup a Babel preset called @babel/preset-env for our little test:
  • Install @babel/preset-env in the same folder as earlier
  • npm install --save-dev @babel/preset-env
  • Create a babel configuration file .babelrc to specify babel to use this preset.
  • {
     "presets": ["@babel/preset-env"]
    }
    
  • And run babel for our test.js again:
  • npx babel test.js
    
  • The output now is backward compatible JavaScript:
  • "use strict";
    [1, 2, 3, 4, 5].map(function (n) {
      return console.log(n);
    });
    

What happened above is that a bunch of Babel plugins transpiled our code with an ES6 feature into backward compatible JavaScript. @babel/preset-env allows us to specify target environment for which to transpile. Not specifying one above caused default target environment to be picked. You can try a few @babel/preset-env targets configurations to see how it affects the babel output.

Babel Polyfill

With Babel Polyfill library, you can simply import @babel/polyfill and it will bring in polyfills for all the ES6+ features into your bundle. While this is the simplest way to deal with polyfills, it also means your JavaScript bundle will include polyfills for all the ES6+ features. So, the bundle for following code snippet will include 30 KB gzipped for babel polyfills with one line of your code:

import "@babel/polyfill";
[1, 2, 3, 4, 5].map(n => console.log(n));


The right way to setup Babel
The right way to setup Babel

Because simply importing @babel/polyfill brings in polyfills for all ES6+ features, it is not the recommended way to setup Babel. Instead, @babel/polyfill should be setup via @babel/preset-env settings to ensures only needed polyfills become part of your JavaScript bundle.

Simply importing @babel/polyfill shall bring polyfill for all ES6+ features into your javaScript bundle. Instead, setup @babel/preset-env with right configuration to ensure only needed polyfills become part of your JavaScript bundle.

Decently Optimal Babel Polyfill Setup

All things stated, below is my safe yet decently optimal babel polyfill recommended setup:

  • Use @babel/preset-env with useBuiltIns: "entry"
  • As a requirement of useBuiltIns, import either @babel/polyfill or core-js + regenerator-runtime at the beginning of entry-point.
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry"
      }
    ]
  ]
}

To optimize the above further:

  • Setup @babel/preset-env target environments in sync with your visitors browser usage.
  • Build separate bundles for modern browsers (those that support atleast ES6) and legacy browsers (pre-ES6) and load specific JS based on browser environment.

Babel Beyond Backward Compatible JavaScript

Many Babel plugins do things beyond transpiling source to backward compatible JavaScript. These plugins benefit from either parsing the source or transforming it during the build process. For example:

ALSO READ:

Webpack

Webpack is a static module bundler. It bundles our source code into a JavaScript file. It does so by starting from an entry-point that we specify to Webpack and building a dependency graph based on imports and requires. Since it does so without actually executing the code, it's dependency analysis is static in nature. It's output is one or more JavaScript files. It is these outputted JS bundles that actually load on visitors' browsers when they access our web-app.

To perform bundling, we provide Webpack with configuration. This configuration shall be written as Node CommonJS module (since webpack runs within Node to do bundling). At it's simplest, we just need to provide an entry point, an output folder and set the build mode (to development or production).

Let's put it in action to understand this better. Let's say we have a bunch of source-code files as following:

  • index.js
  • modules/math.js
  • index.html

Here's our index.js:

import { square, cube } from './modules/math.js';
[1, 2, 3, 4, 5].map((n) => {
        let ele = document.createElement('div');
        ele.innerHTML = 'Element Square = ' + square(n) + ' and Cube = ' + cube(n);
        ele.className = 'box';
        document.body.append(ele);
});

And here's our modules/math.js:

export function square(n)
{
        return n*n;
}
export function cube(n)
{
        return n*n*n;
}

Finally, here's our index.html that shall load & execute our code in browser:

<!doctype html>
<html lang="en">
<body>
	<script src="dist/test.bundle.js"></script>
	</script>
</body>
</html>

To create a single test.bundle.js for our JavaScript source code, we shall have the following (very simple) webpack configuration (within webpack.config.js):

module.exports = {
  mode: 'production',
  entry: './index.js',
  output: {
    path: '/opt/babeltest/dist',
    filename: 'test.bundle.js'
  }
};

We can run webpack-cli to generate test.bundle.js

npm install --save-dev webpack-cli
webpack --config webpack.config.js

Webpack bundler generated test.bundle.js of size ~0.75 KB for me (~4 KB if mode is development). Loading index.html in our browser shall now load & execute the test.bundle.js. In this way, Webpack can bundle multiple source files into a single JS file to load on the browser.


Webpack loaders & plugins : What's the difference?
Webpack loaders & plugins : What's the difference? - I

Webpack Loaders

Let's build further on our source-code example above by adding a style.css to style our boxes:

.box {
  color: white;
  background-color: lightsalmon;
  border: 10px solid salmon;
}

And let's import it within our index.js:

import { square, cube } from './modules/math.js';
import './style.css'
[1, 2, 3, 4, 5].map((n) => {
        let ele = document.createElement('div');
        ele.innerHTML = 'Element Square = ' + square(n) + ' and Cube = ' + cube(n);
        ele.className = 'box';
        document.body.append(ele);
});

Running webpack now would lead to an error Module parse failed: Unexpected token (1:0). This is because it treats style.css as a JavaScript module. To let webpack know to treat CSS files differently, we shall use style-loader and css-loader:

npm install --save-dev style-loader css-loader

We shall update our webpack.config.js to use these two loaders. The below configuration tells webpack to first use style-loader and then css-loader for files that end with .css:

module.exports = {
  mode: 'production',
  entry: './index.js',
  output: {
    path: '/opt/babeltest/dist',
    filename: 'test.bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

This shall generate a single test.bundle.js with required styling CSS within our test.bundle.js file.

Like we saw above, webpack loaders are transformations that allow us to tell Webpack how to load a certain import. There are different loaders for different file types (some are listed here). Infact, the way we specify Webpack to use babel is through a loader called babel-loader. So, it is during loading of source code for bundling, that babel performs it's transpilation. Extending our webpack configuration, below shall ensure our code is babel transpiled (please do a npm install --save-dev babel-loader in prior):

module.exports = {
  mode: 'production',
  entry: './index.js',
  output: {
    path: '/opt/babeltest/dist',
    filename: 'test.bundle.js'
  },
  module: {
    rules: [
        {
        test: /\.js$/i,
        use: [{ loader: 'babel-loader', options: {babelrc:true}}],
        },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

Webpack Plugins

Plugins are transformations that allow us to tell Webpack how to output our bundles. So, while loaders work on individual source file-level, plugins work on output chunks or bundle files levels. Plugins can change how bundles are created.

So, for our earlier example, if we want to extract CSS out of the test.bundle.js into a separate CSS file (say test.bundle.css) - we need to use a Webpack plugin to do so. mini-css-extract-plugin does exactly that and below webpack configuration change demonstrates how we specify plugins (please install the plugin via command npm install --save-dev mini-css-extract-plugin first) to control our output bundles:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
  mode: 'production',
  entry: './index.js',
  output: {
    path: '/opt/babeltest/dist',
    filename: 'test.bundle.js'
  },
  plugins: [new MiniCssExtractPlugin({filename:'test.bundle.css'})],
  module: {
    rules: [
        {
        test: /\.js$/i,
        use: [{ loader: 'babel-loader', options: {babelrc:true}}],
        },
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

Webpack plugin ecosystem has a large number of plugins that can help you control exactly how you would like to bundle & deliver your code. To make sure your output code is minified and/or mangled, there's terser-webpack-plugin. To automate creation of HTML that serves your JS bundles, there's html-webpack-plugin. To generate service-worker with precaching generated JS bundles, there's sw-precache-webpack-plugin. Here's a nice list of some of the common webpack plugins.


Webpack loaders & plugins : what's the difference?
Webpack loaders & plugins : What's the difference? - II

Webpack Optimization

Optimization is a set of transformations Webpack runs during bundling on it's own based on the selected mode (`production` or `development`). For example, minification is one optimization that Webpack performs out of the box for `production` mode. However, we can override mode-dependent defaults for these optimizations if needed. So, if we want to not minify or select a non-default minnification tool, we can do so via webpack configuration.

An important optimization that webpack performs by default is splitting output bundle into multiple chunks (also known as code splitting). It achieves this via split-chunks-plugin. This plugin leverages webpack's static analysis and splits the output JS bundle in a way that only route-specific JS be loaded for a given route. By configuring split-chunks-plugin adequately with the right code-splitting within your front-end code can substantially reduce the amount of JavaScript you load for your web-app to work.

Conclusion

Leveraging boilerplates or utilities like create-react-app enables us to start coding as soon as possible without worrying about build & configuration. While this helps fast development, it often leaves a big hole in our understanding of how the code we write is transformed into the code that runs on our visitors' browsers. Understanding babel, webpack & related parts of the ecosystem well helps us understand this a lot better. This, in turn, ensures that we deliver better & faster web-apps.

ALSO READ:

Punit Sethi

About the Author

Punit Sethi has been working with large e-Commerce & B2C websites on improving their site speed, scalability and frontend architecture. He tweets on these topics here.