Build a Dependency Graph Profiler in JS

Understand dependency graphs in JavaScript

Breaking our app utilities and UI components into files brings modularity to our project. This helps us keep code organized in separate, reusable files, which improves code maintainability.

Each file in the module system exposes its API to the public by using the exports or export keyword. Then, we can reference them by using the require or import keyword.

Any file that we import or require becomes a dependency on the file and as such, we build up a dependency chain that can span a thousand files.

Bundling systems like Webpack use these dependency chains to know which files are to be bundled because the entry file won’t work without them.

An example of smart use of dependency graphs can be found in Bit. Bit isolates your app’s modules/components by tracking your files’ dependencies. This component isolation enables sharing and collaborating on individual components instead of the old way of collaborating solely on full monolithic projects constructed out of unintelligible files.

Example: shared JS utility functions automatically isolated and pushed to Bit’s cloud

In this post, we will see how to build a dependency chain or graph of any file using JavaScript.

How can we construct this graph?

Simple, starting from an entry point (such as main1.js above), we will look for all of the dependencies (other pieces of code that it needs to function) of the file main1.js and construct a graph.

The graph structure gets built up through recursively checking for dependencies within.

Now, how can we achieve that in JS?

First, we get the AST node of the entry file. Now, we see what dependencies of a file are imported using the import keyword or require function call.

Now, these keyword/function calls are represented in the ES tree in different ways.

The import keyword translates to this in EStree:

ImportDeclaration

interface ImportDeclaration {

type: "ImportDeclaration";

specifiers: [ ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier ];

source: Literal;

}

The above represents an import declaration, e.g., import foo from "mod"; . The type property tells us the Node is an ImportDeclaration node. The source is a Node Literal that holds the file name we are importing from.

We have specifiers, we have three kinds of specifiers

ImportSpecifier

interface ImportSpecifier {

type: "ImportSpecifier";

imported: Identifier;

}

An imported variable binding, e.g., {foo} in import {foo} from "mod" or {foo as bar} in import {foo as bar} from "mod" . The imported field refers to the name of the export imported from the module. The local field refers to the binding imported into the local module scope. If it is a basic named import, such as in import {foo} from "mod" , both imported and local are equivalent Identifier nodes; in this case, an Identifier node representing foo . If it is an aliased import, such as in import {foo as bar} from "mod" , the imported field is an Identifier node representing foo , and the local field is an Identifier node representing bar .

ImportDefaultSpecifier

interface ImportDefaultSpecifier {

type: "ImportDefaultSpecifier";

}

A default import specifier, e.g., foo in import foo from "mod.js" .

ImportNamespaceSpecifier

interface ImportNamespaceSpecifier {

type: "ImportNamespaceSpecifier";

}

A namespace import specifier, e.g., * as foo in import * as foo from "mod.js" .

The require is a call so it will be a Node CallExpression :

interface CallExpression {

type: "CallExpression";

callee: Expression;

arguments: [ Expression ];

}

Now, we have seen the Node of the import keyword and require function, what we will do next is to walk through the AST generated, and add filters for the ImportDeclaration and CallExpression nodes. From the filter, we can grab the file dependencies and recursively through the filter to gather the dependencies within.

Let’s start

We first create a folder depGraph and init a node environment:

mkdir depGraph

cd depGraph

npm init -y

We need a library that can parse and walk through the generated AST or create our own. For this post, we will create our own JS AST walker, JSEmitter. We will generate AST with acorn and use our JSEmitter to walk through the AST.