Recently I came across an eslint warning in my project about a circular dependency. I previously thought circular references are impossible in ES modules. But apparently they work in some situations.
I tried to reproduce the minimal working example of a circular dependency.
This works:
// ma.mjs
import { b } from "./mb.mjs";
export const a = 1;
export function fa() {
console.log(b);
}
setTimeout(() => {
fa();
});
// mb.mjs
import { a } from "./ma.mjs";
export const b = 2;
export function fb() {
console.log(a);
}
setTimeout(() => {
fb();
});
// script.mjs (entry point)
import './ma.mjs';
but the same code above without setTimeouts
throws error, or to make it simpler, the code below does not work:
// ma.mjs
import { b } from "./mb.mjs";
export const a = 1;
console.log(b);
// mb.mjs
import { a } from "./ma.mjs";
export const b = 2;
console.log(a);
// script.mjs (entry point)
import './ma.mjs';
The error would be:
Uncaught ReferenceError: Cannot access 'a' before initialization
My questions are:
I've seen this explanation about recursive require
s in NodeJS documentation, but not sure if that also applies to ES modules or not: https://nodejs.org/api/modules.html#modules_cycles . Does ES modules use the exact same mechanism as described here?
Just follow the code logic:
In the synchronous code, if you import ma.mjs
, the first thing it does is import mb.mjs
, which initialises b
, then tries to print a
, which is not set yet.
With timeouts, if you import ma.mjs
, first it imports mb.mjs
(same as before), it sets b
, and puts a task to print a
in the task queue. Then it continues executing ma.mjs
, which sets a
, and puts a task to print b
in the task queue. This completes the initial task, and the queued tasks are fetched to run; but at this point, both a
and b
are defined, so there is no problem.
I.e. it might be better to look at this as a problem of timing, rather than a problem with circular imports. In effect, your examples are equivalent to:
console.log(a) // error
const a = 42
vs
setTimeout(() => console.log(a)) // no error
const a = 42
To analyse exactly what happens in the second snippet:
a
is declared as a constant (declarations, but not initialisations, get hoisted to the top of the scope)a
under closurea
is initialiseda
In the first snippet, the printing itself is sandwiched between declaration and initialisation, where a
does not yet have an explicitly set value.