Search code examples
javascriptcircular-dependencycircular-referenceesmodules

How does EcmaScript handle recursive modules?


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:

  1. what criteria should there be for a circular reference to work? Why my first example works and the second one does not? Although they are both circularly dependent.
  2. How does ES modules handle this recursive situation?

I've seen this explanation about recursive requires 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?


Solution

  • 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:

    1. a is declared as a constant (declarations, but not initialisations, get hoisted to the top of the scope)
    2. a printing task is scheduled, with a under closure
    3. a is initialised
    4. the scheduled printing task runs, and accesses the now-initialised a

    In the first snippet, the printing itself is sandwiched between declaration and initialisation, where a does not yet have an explicitly set value.