Understanding Cyclic Dependencies: CommonJS vs ES Modules
Written on
Chapter 1: Introduction to Cyclic Dependencies
In this article, we will delve into cyclic dependencies and how they are managed by the ECMAScript module (ESM) and CommonJS module systems. We will explore the architectural differences in importing and exporting dependencies across modules. But first, let's clarify what a cyclic dependency is.
Cyclic Dependency Explained
Cyclic dependencies in software arise when two or more modules depend on one another circularly, resulting in a loop in their dependency graph. For instance, consider the following scenario where Module A imports Module B, and Module B imports Module A. This creates a cyclic dependency between the two. Both modules are then imported by main.js, which serves as the entry point for the application.
It's important to note that cyclic dependencies can also be indirect. For example, Module A might import Module C, which in turn imports Module B, which references Module A.
Now that we've defined cyclic dependencies, let's examine the issues they can cause.
Issues Arising from Cyclic Dependencies
The execution flow in programming typically moves from top to bottom, with imported modules at the top executed first. The following diagram illustrates this flow, with green indicating executed code.
To better understand the problem, let's consider what occurs if we attempt to import something from main.js within the 3.js file. At this point, exports from main.js are not yet available, while 3.js attempts to access them during execution.
This common issue in cyclic dependencies is resolved differently across module systems. Let's investigate how CommonJS and ES Modules handle these scenarios.
CommonJS Resolution Approach
Let’s illustrate this with a Node.js example using the CommonJS module system.
// main.js
const helperSay = require('./helper.js');
let say = "I am the Main File";
console.log("MAIN:", say);
console.log("MAIN:", helperSay);
module.exports = say;
// helper.js
const mainSay = require('./main.js');
let say = "I am the Helper File";
console.log("HELPER:", say);
console.log("HELPER:", mainSay);
module.exports = say;
Upon executing this with the command node main.js, the output would be:
HELPER: I am the Helper File
HELPER: {}
MAIN: I am the Main File
MAIN: I am the Helper File
Here, the CommonJS system provides a partially constructed object to the second module in the dependency cycle. As a result, when helper.js accesses main.js, it receives an empty object because the exports from main.js are not yet prepared.
Even if we were to wait using setTimeout to retrieve the exported variable from main.js, helper.js would still receive an empty object.
// helper.js
const mainSay = require('./main.js');
let say = "I am the Helper File";
console.log("HELPER:", say);
setTimeout(() => {
console.log("HELPER:", mainSay);
}, 0);
module.exports = say;
The output remains:
HELPER: I am the Helper File
MAIN: I am the Main File
MAIN: I am the Helper File
HELPER: {}
This demonstrates that CommonJS creates a shallow copy of the exported variables, leading to potential issues with cyclic dependencies.
ES Module Resolution Strategy
Now, let's convert the same example into an ES Module format using the .mjs extension.
// main.mjs
import { say as helperSay } from "./helper.mjs";
export let say = "I am the Main File";
console.log("MAIN:", say);
console.log("MAIN:", helperSay);
// helper.mjs
import { say as mainSay } from "./main.mjs";
export let say = "I am the Helper File";
console.log("HELPER:", say);
console.log("HELPER:", mainSay);
When we run the ES module code using node main.mjs, we encounter the following error:
ReferenceError: Cannot access 'mainSay' before initialization
This error arises because ECMAScript does not allow access to variables before they are initialized, as the same variable is shared between the imported and exported modules.
However, if we introduce a delay before accessing the imported variable, we can retrieve the updated value:
import { say as mainSay } from "./main.mjs";
export let say = "I am the Helper File";
console.log("HELPER:", say);
setTimeout(() => {
console.log("HELPER:", mainSay);
}, 0);
The output will be:
HELPER: I am the Helper File
MAIN: I am the Main File
MAIN: I am the Helper File
HELPER: I am the Main File // Correct value from the imported module
This confirms that unlike CommonJS, ES Modules allow for updated values to be accessed, thanks to their live binding feature.
Conclusion
In summary, both CommonJS and ES Modules exhibit distinct behaviors when addressing cyclic dependencies. CommonJS provides a partially empty object during circular references, while ECMAScript raises an error. However, due to live binding, ECMAScript modules can retrieve updated values later, making them more robust for handling cyclic dependencies.
Thank you for reading this article. We hope you gained valuable insights. Be sure to subscribe and stay tuned for more content!
Chapter 2: Additional Resources
To further explore the differences between CommonJS and ES Modules, check out the following videos:
A comprehensive comparison of ESM and CommonJS in Node.js projects.
A detailed discussion on CommonJS vs ES Modules in Node.js.