Migrating an old JavaScript codebase to modern JavaScript May 18 2020 Latest Update: May 25 2020

We get excited about starting a new project. It allows us to use new technologies, and we don't have to worry about being compatible with any old code. In reality, most of the work we do is maintenance. And I do believe, that working with legacy code requires more skills and creativity than starting a new project from scratch with zero limitations.

Whatever the case might be, refactoring JavaScript code to modern JavaScript is challenging. In this post, we are going to visit many aspects of the process of bringing our old JavaScript code to modern JavaScript.

Let's start by defining the problem.

Defining the problem

If the current code works, so there is not much incentive to change it. So why update it? In general, to pay the technical debt, most of the old JavaScript were mainly files (.js) that contain lots of global functions and variables. The scripts begin life very simple, but after some time they grow into a messy codebase.

In general, JavaScript the language and the community didn't have the tools that it now has. Now we know much more, and we can take advantage of new techniques, like modules, that makes our codebase better. We don't have to change the whole codebase to use the latest framework and add thousands of dependencies (which now is the problem with JS). But we should maintain our code and keep it up to date so making changes is easy. We don't want to take weeks to implement new functionality because we are afraid to break something.

Ok what are we going to do:

Prerequisites

We'll use Yarn to install and manage the JavaScript tools that will allow us to bundle (Webpack1) and transpile(Babel2) our Javascript code. To install Yarn follow the instructions here.

Installing and configuring Webpack and Babel

Let's start by initialising our JavaScript environment. Do this where ever you are going to put your JavaScript source code. Remember, we will be generating a bundle, and that bundle is the one your web server needs. So don't run this command in the web server's public directory.

If you want to follow along, let's create a directory called webpack-demo:

1
2
$ mkdir webpack-demo
$ cd webpack-demo

Initialise the project with the following command:

1
$ yarn init

This command generates Yarn's config file (package.json). The command will prompt you to answer a few questions. Don't worry, you can change the configuration later directly on the file.

Now we are going to begin by installing Webpack.

1
2
$ yarn add webpack webpack-cli
$ yarn install

If you check your package.json file, you'll see Webpack as part of the project's dependencies.

Configuring Webpack

With Webpack installed, we need to configure it. We need to create the file webpack.config.js (which is a node script). If you want to learn more, you can check Webpack's documentation here.

I'll show you a general configuration, but you can modify it to fit your needs. We are using NodeJS's path module to generate a correct path.

1
2
3
4
5
6
7
8
9
10
//webpack.config.js
const path = require('path');

module.exports = {
    entry: path.join(__dirname,"src","original.js"),
    output: {
        path: path.join(__dirname, "public"),
        filename: "bundle.js",
    }
};

We are telling Webpack to search for original.js fetch all the dependencies defined in that file, and bundle them in a file called bundle.js.

Let's begin by creating two directories:

1
$ make src public

Create the file original.js inside src and add the following content:

1
2
3
4
5
function greeting(name) {
 alert("Hello, "+ name);
}

greeting("Derik");

We should have the following directory structure:

1
2
3
4
5
6
webpack-demo/
├── package.json
├── public
├── src
│  └── original.js
└── yarn.lock

We can now run webpack and see the generated bundle.js in the public directory:

1
$ webpack

We can now check the public directory, and we'll find our bundle.js there:

1
2
public/
└── bundle.js

We can type webpack every time, or we can make use of Yarn scripts. Yarn gives us the option to define small commands that will trigger the execution of a package script. Add the following element to our package.json:

1
2
3
4
5
...
"scripts": {
    "build": "webpack"
},
...

Now you'll be able to run:

1
$ yarn run build

It might feel like overkill, but once you start adding parameters to Webpack, it'll make it easier to avoid typing mistakes (and memorising commands).

My package.json looks something similar to this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
 "name": "webpack-demo",
 "version": "1.0.0",
 "description": "just a webpack babel demo",
 "main": "src/original.js",
 "author": "Derik Ramirez",
 "license": "MIT",
 "dependencies": {
  "webpack": "^4.43.0"
 },
 "devDependencies": {
  "webpack-cli": "^3.3.11"
 },
 "scripts": {
  "build": "webpack"
 }
}

Let's create an HTML file to use our JavaScript, and spin a local web server so we can check everything is working correctly.

Test our JavaScript on a Web Server

Let's create the index.html file for our test. Create it under public directory and add the following content:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Webpack Demo</title>
 <script src="./bundle.js"></script>
</head>
<body>
 <h1>Hi!</h1>
</body>
</html>

You probably have a Web server where you run your legacy code. But for testing purposes, we'll spin an HTTP server using ruby's WeBrick server.

Inside our webpack-demo directory run the following command. You can use any other HTTP server if you prefer (e.g. Python SimpleHTTPServer or JavaScript live-server).

1
$ ruby -run -e httpd public -p 8000

If you visit your local webserver:

1
http://localhost:8000

You should be able to see the webpage with the alert showing our greeting.

If we make any changes to our orginal.js, like adding a new function, we have to regenerate bundle.js by running the command yarn run build again.

Alright, that's it for bundling old self-contained JavaScript files. The problem with this is that the function greeting will only be accessible from inside the original.js bundle. What if we have an inline <script> that calls that function.

Before making sure our <script>s won't be broken, let's have a small refresh on JavaScript modules.

Working with modules

I'll not make this a post about JavaScript modules, you'll have to research that on your own. But as an oversimplification, let's focus on modules scope. In our modules, we can define which elements of our module to make public by exporting them. In the same way, we can import modules to our code, by specifying which of the exported functions form the module we would like to import.

Let's see a quick example:

ES6/ES7 import and export

Let's say we have the following file other.js with the content:

1
2
3
4
const square = (x) => x * x;
const add = (a,b) => a + b;
const subtract = (a,b) => a - b; 
export { square, add };

This module exports two functions square and add. The third function subtract remains inaccessible from outside. From another script, maybe original.js, we can then import them like so:

1
2
3
import { square } from "./other.js"; 

console.log(square(2));

This will tell JavaScript that we would like to use the function square from other.js. We don't have to import all of the exported functions, just the ones we need.

Back to our problem

As we discussed, we are trying to migrate our old JavaScript to modern JavaScript. The problem we have now is that our function remains inside the original.js module after Webpack bundles it into bundle.js making it impossible for us to call it from outside the original.js script.

If we would call our greeting function from the index.html file we would get an error. Imagine we have the following code in index.html:

1
2
3
 <script>
  greeting("New User");
 </script>

If you open the developer console of your browser while visiting http://localhost:8000/, you'll see:

1
ReferenceError: greeting is not defined

You could imagine it is because we are not exporting that function. But there is more to it. The following is our current code:

1
2
3
4
5
function greeting(name) {
 alert("Hello, "+ name);
}

greeting("Derik"); //Let's remove this line, so we are not triggering an alert just by loading the code

We need to add the export to make it available.

1
2
3
export function greeting(name) {
 alert("Hello, "+ name);
}

But also Webpack by default exports a Script it doesn't publish a library that can be "imported" elsewhere. We need to use output.libraryTarget to change Webpack behaviour and tell it that we are exporting a library and how to make it's export's available. The libraryTarget field supports many options. We are going to make it accessible by making all the exports part of the window object, basically making them global.

1
  libraryTarget: "window"

Before anyone comes with pitchforks telling that this is what we had before, everything in the global scope. I know, but this is the first step. We are going to refactor our code, but first, we need to make sure that if there are any <script> tags that require our function they still work. Then we can systematically go about replacing them.

I will encourage you to have a look at the documentation for output.libraryTarget. Read and try to understand the options they might come useful for your specific case. In this post, I'm only dealing with general cases.

To summarise here is the content of our files:

original.js:

1
2
3
export function greeting(name) {
 alert("Hello, "+ name);
}

index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Webpack Demo</title>
 <script src="./bundle.js"></script>
</head>
<body>
 <h1>Hi!</h1>
 <script>
  greeting("New User");
 </script>
</body>
</html>

webpack.config.js:

1
2
3
4
5
6
7
8
9
10
11
const path = require('path');

module.exports = {
 entry: path.join(__dirname,"src","original.js"),
 output: {
  path: path.join(__dirname, "public"),
  filename: "bundle.js",
  libraryTarget: "window"
 }
};

We can now build our bundle again:

1
$ yarn run build

And refresh the webpage.

You'll see our code working.

Next steps

If you follow along in your project, the time is now when you'll refactor. The refactoring will be very different for each project. And how much refactoring you do, depends on your knowledge of modern JavaScript. So This is where I'll leave you to follow your own path. Good luck!

The general post will stop here. We saw an overview of how to get our old JavaScript scripts and integrate them using Webpack. The following sections are additional configurations or details that you might find useful.

Transpiling and configuring Babel

To transpile ES6 to ES5, we'll need to add to our dependencies babel-core and babel-loader (Webpack loader). Currently, there is a conflict if I don't specify the versions of those packages, so in this example, I'll specify them. Maybe by the time you are reading this, it'll already be fixed:

1
$ yarn add babel-core babel-loader@7

If we ran Babel from the console we could execute a command similar to this:

1
$ babel src/original.js --out-file=public/app.js [--presets=env] [--watch]

We could set up a yarn script inside package.json

1
2
3
4
5
...
"scripts": {
    "build-babel": "babel src/some_file.js --out-file=public/some_other.js --presets=env"
},
...

But we are going to be using loaders. Webpack uses loaders to transform modules. We can transform the module by passing it through babel before bundling. Let's specify the rule to execute this loader when it finds a file ending in .js.

In webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//webpack.config.js
const path = require('path');

module.exports = {
    entry: "./src/original.js",
    output: {
        path: path.join(__dirname, "public"),
        filename: "bundle.js"
    },
    module: {
        rules: [{
            loader: "babel-loader",
            test: /\.js$/,
            exclude: /node_modules/
        }]
    },
};

If we need to add some configuration to Babel, you'll need to create babel.rc and ad the configuration there. For example:

1
2
3
4
5
{
    "presets": [
        "env",
    ]
}

Check Babel website for more documentation:

Source maps

When we bundle our files, and we get an error in the code, the error line we get is inside the bundle making it hard to figure out which of our source files contains the offending code. We'll use source maps to solve that problem. All modern web browsers support them. With the source maps the browser can get the error from the bundle and show us the location in our source files (the files that used to create the bundle).

We will use Webpack's devtool to get the source map. Again, check the documentation for more options.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//webpack.config.js
const path = require('path');

module.exports = {
    entry: "./src/original.js",
    output: {
        path: path.join(__dirname, "public"),
        filename: "bundle.js"
    },
    module: {
        rules: [{
            loader: "babel-loader",
            test: /\.js$/,
            exclude: /node_modules/
        }]
    },
    webtool: "cheap-module-eval-source-map"
};

Webpack DevServer

In this post, we used ruby to spin an HTTP server to view our changes. Webpack has its own DevServer, check the documentation here:

Webpack DevServer

Using ES6 class properties

Babel has a useful plugin to use ES6 class properties. Check the documentation here:

https://babeljs.io/docs/en/babel-plugin-transform-class-properties/

We just need to add the plugin to the babel.rc

1
2
3
4
5
6
7
8
{
    "presets": [
        "env",
    ],
    "plugins": [
        "transform-class-properties"
    ]
}

Babel will run that plugin every time Webpack executes Babel.

Webpack for production

We might want to create two different scripts for yarn to run when building for different environments. One for development and one for production. That way, we can run:

1
2
3
4
#For development
$ yarn run build-dev
#For production
$ yarn run build-prod

The package.json will look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
 "name": "webpack-demo",
 "version": "1.0.0",
 "description": "just a webpack babel demo",
 "main": "src/original.js",
 "author": "Derik Ramirez",
 "license": "MIT",
 "dependencies": {
 "webpack": "^4.43.0"
 },
 "devDependencies": {
 "webpack-cli": "^3.3.11"
 },
 "scripts": {
 "build-dev": "webpack"
 "build-prod": "webpack -p --env production"
 }
}

As you can see the new build-prod will pass production to Webpack as an argument.

Now we need to change the Webpack configuration to take advantage of the argument passed to the environment variable.

We are going to check the value on that variable, and if it is "production" we can decide to use a different configuration. One example where this is useful is source maps.

Source maps make our bundle big. By using a source map designed for production, we can improve the size of our bundle and only load the source map when the user checks the developer console.

We are going to change the devtool used to generate source maps. If we check the documentation, we can see the different options. We'll use source-map for production and cheap-module-eval-source-map for development. Here is the link to the documentation:

https://webpack.js.org/configuration/devtool/

The webpack.config.js will look similar to this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const path = require('path');
const js_path = path.join(__dirname, "src/")

module.exports = (env) => {
 const isProduction = env === 'production';
 return {
  mode: isProduction ? 'production' : 'development',
  entry: {
   bundle: path.join(js_path, "original.js"),
  },
  output: {
   path: js_path,
   filename: "[name].js",
   libraryTarget: "window"
  },
  devtool: isProduction ? 'source-map' : "cheap-module-eval-source-map",
  module: {
   rules: [{
    loader: "babel-loader",
    test: /\.js$/,
    exclude: /node_modules/
   }]
  },
 };
};

Improve development by adding watch to Webpack

When developing, we don't want to have to build the bundles every time we make a change on the JavaScript. So we are going to tell Webpack to watch the files for changes and regenerate if there are any changes.

We'll add the following option (Based on isProduction):

1
  watch: !isProduction,

The whole webpack.config.js looks now like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const path = require('path');
const js_path = path.join(__dirname, "src/")

module.exports = (env) => {
 const isProduction = env === 'production';
 return {
  mode: isProduction ? 'production' : 'development',
  entry: {
   bundle: path.join(js_path, "original.js"),
  },
  output: {
   path: js_path,
   filename: "[name].js",
   libraryTarget: "window"
  },
  devtool: isProduction ? 'source-map' : "cheap-module-eval-source-map",
  watch: !isProduction,
  module: {
   rules: [{
    loader: "babel-loader",
    test: /\.js$/,
    exclude: /node_modules/
   }]
  },
 };
};

Now we have an excellent way to differentiate between a build for development and a build for production. For more information about using Webpack for production check documentation:

https://webpack.js.org/guides/production/

If we want to do CSS/SCSS with Webpack

We can add a new Rule's array.

We need to use two two loaders:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//webpack.config.js
const path = require('path');

module.exports = {
    entry: "./src/original.js",
    output: {
        path: path.join(__dirname, "public"),
        filename: "bundle.js"
    },
    module: {
        rules: [{
            loader: "babel-loader",
            test: /\.js$/,
            exclude: /node_modules/
        },
        {
            test: /\.css$/,
            use: [
                'style-loader',
                'css-loader'
            ]
        },      
        ]
    },
    webtool: "cheap-module-eval-source-map"
};

Then we can do an Import on our original.js

1
import "./styles/style.css";

And Webpacker would be able to bundle our CSS files into style.css.

If you are going to use SCSS, we would need to use a different loader. Add to your yarn dependencies: sass-loader and node-sass (similar to babel-core)

For CSS Production

So we can get source maps.

CSS Loader and extract-text-webpack-plugin

Final Thoughts

As you saw there are so many aspects of using modern JavaScript. You'll have to check if it is really necessary for your project. If you are only using simple JavaScript on your project, you shouldn't add this overhead. But when your codebase has grown to a point that it is painful to do changes and it would benefit for refactor, you could use some of this notes to improve your project.

I'll try to keep this note updated If anything else comes to mind. So let me know if it was helpful.


  1. "At its core, webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or more bundles." - https://webpack.js.org/concepts/ 

  2. "Babel is a JavaScript compiler. Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments." - https://babeljs.io/docs/en/ 


** If you want to check what else I'm currently doing, be sure to follow me on twitter @rderik or subscribe to the newsletter. If you want to send me a direct message, you can send it to derik@rderik.com.