Write modern javascript for older browsers with babel and browserify

Table of contents

Even though javascript has evolved over the years and added numerous features to fix design issues and make developer's lives easier, there are still a lot of browsers in use today that won't even support a fraction of those new essential features. Here is how you can write modern javascript code and still support older browsers that may not have all the features you are using.

Transpiling modern javascript into legacy syntax

You might have heard of transpiling source code from one language into source code of another, mostly done for performance. There is another application for transpilers though: Transpiling modern javascript source code into and older syntax, so you can use all the new features during development and still support a wide range of browsers without worrying about compatibility issues.

The tool for this job is BabelJS. You can easily install it by running:

npm install --save-dev core-js@3 @babel/core @babel/cli @babel/preset-env

Notice how we also installed core-js. CoreJS includes polyfills for javascript features. A polyfill is a piece of code that emulates a part of the javascript language in case it is not available in the browser. While BabelJS will handle syntax transforms, it does not emulate missing browser features or apis. What it can do however, is include imports of CoreJS polyfills when it encounters such features being used.

Transpiling source code with BabelJS

To start transpiling javascript code, we need to define an environment first. An environment specifies the types and versions of browser we want to support. The selection is done using the browserslist module. Browserslist enables us to create a target set of supported browsers by writing an easily-understandable inclusion query in a text file. A sample query could look like >0.2%, supports css-grid, last 2 versions, not dead. This would add support for the 2 most recent versions of all browsers that have at least 0.2% global usage, support the css-grid feature and aren't discountinued (like Internet Explorer).

The configuration is written to a file named .browserslistrc. The convenience query defaults is a sane starting point for any application, but you can fine-tune it to your liking:

.browserslistrc

defaults, >0.2%, not dead

Additionally, we need to create a configuration file for babel to define the corejs version and change the behaviour of builtins:

babel.config.json

{
  "presets" : [
     [
        "@babel/preset-env",
        {
           "modules": false,
           "corejs" : "3.22",
           "useBuiltIns" : "usage"
        }
     ]
  ]
}

The important part is the useBuiltins: "usage" setting. This enables checking your source code for missing features and automatically importing the correct core-js polyfill module for it, but only those that are actually used by your source code.

Now that the setup is complete, we can transpile a sample file:

index.js

new Promise().then().catch().finally()
() => 2;
let x = "world"
console.log(`hello ${x}`);

This uses some ES6 features like arrow functions, template strings and promises, as well as the ES9 finally() method for promises. Since our target browser environment is very restrictive, none of the mentioned features is supported by all browsers in our browserslist query, so they will get transformed by BabelJS.

Let's see BabelJS do that:

./node_modules/.bin/babel index.js > bundle.js

This creates a new file bundle.js with the result of the transpilation process:

import "core-js/modules/es.object.to-string.js";
import "core-js/modules/es.promise.js";
import "core-js/modules/es.promise.finally.js";
new Promise().then().catch().finally();
var x = "world";
console.log("hello ".concat(x));
(function () {
  return 2;
});

Just as defined, it rewrote the syntax it could, namely the arrow function and template strings, and imported polyfills for missing browser features that can't just be solved with a simple syntax rewrite.

Packaging for web frontends

Since we have transpiled the source code now, it is compatible with most of our browser preset. But there are some caveats:

  • Browsers that do not support ES6-modules will still be incompatible
  • You will need to expose the node_modules/ directory in production in order to make the import statements work
  • The imported polyfill modules need to be available relative to the script, which may be problematic for multi-page websites

Luckily, there is a tool to help us resolve all of these: Browserify. What it does is very simple: it bundles your main javascript and all imported modules together into a single file, so nothing has to be imported and no other files need to be available at specific locations anymore.

To install it and it's BabelJS plugin into our project, run

npm install --save-dev babelify browserify

Now you can simply execute it:

./node_modules/.bin/browserify index.js > bundle.js

The resulting bundle.js file now looks like this:

(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
new Promise().then().catch().finally()
let x = "world"
console.log(`hello ${x}`);
() => 2;
},{}]},{},[1]);

No more import statements anywhere, but the polyfills are still included!

Making the bundle production-ready

There are a bunch of steps to get a javascript bundle like the one we created to a production-ready stage, like minification and dead code removal. Those features are available with the tinyify plugin for browserify.

npm install --save-dev tinyify

This plugin does all the required steps fully automatically, so we can simply run it on the generated bundle:

./node_modules/.bin/browserify -p tinyify bundle.js > prod.js

Our production-ready prod.js file should now contain just a single line of code:

!function r(e,n,o){function t(u,f){if(!n[u]){if(!e[u]){var l=require;if(!f&&l)return l(u,!0);if(i)return i(u,!0);var c=new Error("Cannot find module '"+u+"'");throw c.code="MODULE_NOT_FOUND",c}var a=n[u]={exports:{}};e[u][0].call(a.exports,(function(r){return t(e[u][1][r]||r)}),a,a.exports,r,e,n,o)}return n[u].exports}for(var i=require,u=0;u<o.length;u++)t(o[u]);return t}({1:[function(r,e,n){(new Promise).then().catch().finally(),console.log("hello world")},{}]},{},[1]);

A lot of polyfill code was minified and dead code, like our mock arrow function that was never called, was removed entirely, leaving only a very small version of our bundle that only contains the lines that are truly necessary to make the script do what we initially programmed it to do.

More articles

5 neat javascript tricks to make your life easier

Making your code more elegant with lesser-known language features

Embracing python context managers

Simplifying your code by automating resource handling

Getting help from linux man pages

Learning to navigate the linux manual pages to be productive without searching the internet

Editing files with nano

Terminal file-editing made easy

Understanding bash stream redirection

Controlling the flow of data in and out of programs

Working with tar archives

...and tar.gz, tar.bz2 and tar.xz