How to Add Extra Functionality to Your Modules With Babel

How to Add Extra Functionality to Your Modules With Babel

A short introduction to AST
Ferenc Almasi β€’ Last updated 2021 November 11 β€’ Read time 13 min read
Find out how abstract syntax tree works, and how you can use it with the help of Babel, to build powerful plugins to enhance your code.
  • twitter
  • facebook
JavaScript

Babel, the JavaScript compiler has gone a long way since its initial release back in 2014. Babel β€” originally called 6to5 (which turned ES6+ code into ES5) β€” now has a striving community with many plugins created for you to get the most out of your code.

While Babel is mostly used to transpile code down to a lower version of JavaScript to make your code compatible with a wide range of browsers, it can be used for more than that. Since it works in three stages: parsing, transforming, and printing, you also have access to an AST.

What is an AST?

An abstract syntax tree, or AST for short, is a tree representation of your source code. For example, for a simple console.log like the one below, you would get a complex tree back with every possible metadata about your code.

Copied to clipboard!
console.log('πŸ‘‹')
The abstract syntax tree of a console log

This is what will help us create a plugin that can wrap our code with additional functionality, so we can keep our codebase simpler, without including extra wrappers for every file.

Imagine that we have a site with multiple routes, and we want to execute different codes for different routes, based on the file name. Let’s say for the sake of demonstration, we will also need to only execute the code after a custom event has been fired, just to make sure our app is ready. Eventually, we want to use Babel to turn this:

Copied to clipboard!
import moduleA from 'moduleA'
import moduleB from 'moduleB'

console.log('I\'m only firing on the home page');
home.js
Based on the file name, this would be executed for /home

into to this:

Copied to clipboard! Playground
import moduleA from 'moduleA'
import moduleB from 'moduleB'

if (document.location.pathname === '/home') {
    document.addEventListener('app:ready', () => {
        console.log('I\'m only firing on the home page');
    });
}
home.js

How Babel Plugins Work

So how do Babel plugins work? First, you will need to create a new file for your plugin that exports a function. This function can take babel as an argument, so you can access its API.

Copied to clipboard!
module.exports = babel => {
    // Your plugin's body
}
plugin.js

To make this plugin called during compile, you need to specify it in your .babelrc config file:

Copied to clipboard!
{
    "plugins": [
        "./path/to/your/plugin.js"
    ]
}
.babelrc

Exploring AST

Now it would be pretty insane to remember how your code is built up, and what is its AST representation. Luckily for us, there is an awesome online tool called AST Explorer, that is made for this very purpose.

Exploring AST

You can not only explore JavaScript with AST Explorer, but also other languages, including web technologies such as HTML, or CSS. You also have the option to explore code with different parsers, such as Flow or TypeScript.

Looking to improve your skills? Check out our interactive course to master JavaScript from start to finish.
Master JavaScriptinfo Remove ads

Building the Plugin With a Visitor

For this plugin to work, we’re going to need to use a visitor. A visitor is used for traversing through the code. In a visitor, you are visiting nodes where you can do all sorts of transformation based on their metadata. Nodes in an AST are objects with similar structures. Each node has a type. For example, the following are all different nodes with different types:

Copied to clipboard!
const fun = () => {} -> "VariableDeclarator"
function fun () {}   -> "FunctionDeclaration"
'🌲' === '🌲'        -> "BinaryExpression"
console.log('🌳')    -> "CallExpression"

To use a visitor in a Babel plugin, all you have to do is return an object from your module that has a visitor node:

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: { ... }
    };
}
plugin.js

Based on the type of the node, you can add properties to this object, that will be called for every type found in the tree.

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: {
            VariableDeclarator: { ... }
        }
    };
}
plugin.js
This will be called for every VariableDeclarator

Since we are not interested in a single type, but the whole file, we want the plugin to run for the whole code. If you go back to AST Explorer, you will notice that the topmost type of your file is called a Program, so you need to pass this to visitor:

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: {
	    Program: {
		enter: () => console.log('entered to node');
                exit:  () => console.log('exited from node');	
	    }
	}
    };
}
plugin.js

When you create a visitor, you have two ways to visit a node:

  • You can define an enter function that will be called when a type is encountered and the visitor enters into it
  • You can define an exit function that will be called when the visitor exits a node

Also, if you don’t care about the exit function, you can simply write:

Copied to clipboard! Playground
visitor: {
    Program() { ... }
}

// Which is equivalent to
visitor: {
    Program {
        enter: () => { ... }
    }
}
plugin.js

This function can take two arguments: a path and a parent, both representing links between different nodes. For example, the path in our case references the Program type, while the parent references its parent, that is the file itself. We’re going to need to use both. Let’s say we only want the plugin to target a subfolder in our codebase. This can be done with a simple if statement:

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: {
	    Program(path, parent) {
                if (parent.filename.includes('src\\routes')) {
                    // This plugin will only run for files in the routes folder.
                }
	    }
        }
    };
}
plugin.js

Inside this if, we can get the name of the file using the path to the file, and we can get Babel to parse a string into code using babel.parse, and replace our existing file with it, getting a half working solution:

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: {
	    Program(path, parent) {
                if (parent.filename.includes('src\\routes')) {
                    const filePath = parent.filename.split('\\');
                    const fileName = filePath[filePath.length - 1].split('.')[0];
                    const code = `
                        if (document.location.pathname === '/${fileName}') {
                            document.addEventListener('app:ready', () => {
                                ${parent.file.code}
                            });
                        }
                    `;
                    
                    // Replace the `Program` with the `code` variable
                    path.replaceWith(babel.parse(code).program);
                }
	    }
        }
    };
}
plugin.js
Using parent.file.code, you can get the whole code from your file as a string

Only a half working solution, because if you were to test this code by adding a file into your src/routes folder, you will notice Babel will throw the following Error:

Maximum call stack size exceeded

Preventing infinite loops

This happens because using replaceWith generates a new Program which makes Babel visit it again because of the change, and when you call replaceWith again, the cycle continues, resulting in an infinite loop. To battle this, simply wrap your if into another if to check if the file has already been transformed:

Copied to clipboard! Playground
module.exports = babel => {
    return {
        visitor: {
	    Program(path, parent) {
                if (parent.filename.includes('src\\tracking')) {
                    if (!this.wrapped) {
                        const filePath = parent.filename.split('\\');
                        const fileName = filePath[filePath.length - 1].split('.')[0];
                        const code = `
                            if (document.location.pathname === '/${fileName}') {
                                document.addEventListener('app:ready', () => {
                                    ${parent.file.code}
                                });
                            }
                        `;

                        path.replaceWith(babel.parse(code).program);
                    
                        this.wrapped = true;
                    }
                }
	    }
        }
    };
}
plugin.js

This is now working as expected, but there’s one problem still. You can’t use import statements, because you will get another error this time, saying:

unknown: 'import' and 'export' may only appear at the top level

It’s clear that this is happening because we wrap the whole code into an if statement, but imports should be at the top of your file.

Keeping imports at the top

To keep imports at the top, we can separate the nodes based on types. All nodes that has a type of ImportDeclaration should go to the top, while everything else should go between the if.

The body of the program in AST Explorer
The body of the Program

Again, if you go back to AST Explorer, you can see that the body of our program is an array of different types. We can loop through this and check for import statements to separate them from the rest of the code:

Copied to clipboard!
const imports = path.node.body.filter(node => node.type === 'ImportDeclaration');
const expressions = path.node.body.filter(node => node.type !== 'ImportDeclaration');
plugin.js
Collecting import statements

To also turn them into strings, so we can place them into the code and let Babel parse it back, we can use parent.file.code which returns the whole file as a string. But we only need the individual nodes as strings.

If you look at the image above, you will note that each type has a start and end property. This defines where the node starts and where it ends. We can use these to strip parent.file.code and only get back the relevant part:

Copied to clipboard! Playground
const imports = path.node.body
    .filter(node => node.type === 'ImportDeclaration')
    .map(statement => parent.file.code.slice(statement.start, statement.end));

const expressions = path.node.body
    .filter(node => node.type !== 'ImportDeclaration')
    .map(statement => parent.file.code.slice(statement.start, statement.end));

const filePath = parent.filename.split('\\');
const fileName = filePath[filePath.length - 1].split('.')[0];

const code = `
    ${imports.join('\n')}

    if (document.location.pathname === '/${fileName}') {
        document.addEventListener('app:ready', () => {
            ${expressions.join('\n')}
        });
    }
`;

path.replaceWith(babel.parse(code).program);
plugin.js

This way, we can keep the import statements at the top of our files, and put the rest into the if statement. Note that with this solution, you are adding an extra step for Babel. You are converting each node to a string and then make Babel parse it back. With a more complex solution, you can work on nodes directly and completely avoid the parse step on line:22.


Conclusion

Babel became a powerful tool over the years. It not only lets you transpile your code down to make it work across multiple browsers but also lets you build powerful plugins that can enhance your code, as well as the productivity of developers. It can help you get rid of repetitive tasks, and let you focus on building things.

If you would like to learn more on how to build plugins in Babel, I highly recommend checking out The Babel Plugin Handbook on GitHub, which is a great resource for diving deeper into the world of AST.

Have you already built plugins with Babel before? Show us your work in the comments section below! Thank you for reading through, happy building! πŸ—οΈ

How to Build Plugins for Figma
  • twitter
  • facebook
JavaScript
Did you find this page helpful?
πŸ“š More Webtips
Mentoring

Rocket Launch Your Career

Speed up your learning progress with our mentorship program. Join as a mentee to unlock the full potential of Webtips and get a personalized learning experience by experts to master the following frontend technologies:

Courses

Recommended

This site uses cookies We use cookies to understand visitors and create a better experience for you. By clicking on "Accept", you accept its use. To find out more, please see our privacy policy.