How to fix this ES6 module circular dependency? How to fix this ES6 module circular dependency? javascript javascript

How to fix this ES6 module circular dependency?


The answer is to use "init functions". For reference, look at the two messages starting here: https://esdiscuss.org/topic/how-to-solve-this-basic-es6-module-circular-dependency-problem#content-21

The solution looks like this:

// --- Module Aimport C, {initC} from './c';initC();console.log('Module A', C)class A extends C {    // ...}export {A as default}

-

// --- Module Bimport C, {initC} from './c';initC();console.log('Module B', C)class B extends C {    // ...}export {B as default}

-

// --- Module Cimport A from './a'import B from './b'var C;export function initC(){    if (C) return;    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }}initC();export {C as default}; // IMPORTANT: not `export default C;` !!

-

// --- Entrypointimport A from './A'console.log('Entrypoint', new A) // runs the console.logs in the Cconstructor.

Also see this thread for related info: https://github.com/meteor/meteor/issues/7621#issuecomment-238992688

It is important to note that exports are hoisted (it may be strange, you can ask in esdiscuss to learn more) just like var, but the hoisting happens across modules. Classes cannot be hoisted, but functions can be (just like they are in normal pre-ES6 scopes, but across modules because exports are live bindings that reach into other modules possibly before they are evaluated, almost as if there is a scope that encompasses all modules where identifiers can be accessed only through the use of import).

In this example, the entry point imports from module A, which imports from module C, which imports from module B. This means module B will be evaluated before module C, but due to the fact that the exported initC function from module C is hoisted, module B will be given a reference to this hoisted initC function, and therefore module B call call initC before module C is evaluated.

This causes the var C variable of module C to become defined prior to the class B extends C definition. Magic!

It is important to note that module C must use var C, not const or let, otherwise a temporal deadzone error should theoretically be thrown in a true ES6 environment. For example, if module C looked like

// --- Module Cimport A from './a'import B from './b'let C;export function initC(){    if (C) return;    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }}initC();export {C as default}; // IMPORTANT: not `export default C;` !!

then as soon as module B calls initC, an error will be thrown, and the module evaluation will fail.

var is hoisted within the scope of module C, so it is available for when initC is called. This is a great example of a reason why you'd actually want to use var instead of let or const in an ES6+ environment.

However, you can take note rollup doesn't handle this correctly https://github.com/rollup/rollup/issues/845, and a hack that looks like let C = C can be used in some environments like pointed out in the above link to the Meteor issue.

One last important thing to note is the difference between export default C and export {C as default}. The first version does not export the C variable from module C as a live binding, but by value. So, when export default C is used, the value of var C is undefined and will be assigned onto a new variable var default that is hidden inside the ES6 module scope, and due to the fact that C is assigned onto default (as in var default = C by value, then whenever the default export of module C is accessed by another module (for example module B) the other module will be reaching into module C and accessing the value of the default variable which is always going to be undefined. So if module C uses export default C, then even if module B calls initC (which does change the values of module C's internal C variable), module B won't actually be accessing that internal C variable, it will be accessing the default variable, which is still undefined.

However, when module C uses the form export {C as default}, the ES6 module system uses the C variable as the default exported variable rather than making a new internal default variable. This means that the C variable is a live binding. Any time a module depending on module C is evaluated, it will be given the module C's internal C variable at that given moment, not by value, but almost like handing over the variable to the other module. So, when module B calls initC, module C's internal C variable gets modified, and module B is able to use it because it has a reference to the same variable (even if the local identifier is different)! Basically, any time during module evaluation, when a module will use the identifier that it imported from another module, the module system reaches into the other module and gets the value at that moment in time.

I bet most people won't know the difference between export default C and export {C as default}, and in many cases they won't need to, but it is important to know the difference when using "live bindings" across modules with "init functions" in order to solve circular dependencies, among other things where live bindings can be useful. Not to delve too far off topic, but if you have a singleton, alive bindings can be used as a way to make a module scope be the singleton object, and live bindings the way in which things from the singleton are accessed.

One way to describe what is happening with the live bindings is to write javascript that would behave similar to the above module example. Here's what modules B and C might look like in a way that describes the "live bindings":

// --- Module BinitC()console.log('Module B', C)class B extends C {    // ...}// --- Module Cvar Cfunction initC() {    if (C) return    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }}initC()

This shows effectively what is happening in in the ES6 module version: B is evaluated first, but var C and function initC are hoisted across the modules, so module B is able to call initC and then use C right away, before var C and function initC are encountered in the evaluated code.

Of course, it gets more complicated when modules use differing identifiers, for example if module B has import Blah from './c', then Blah will still be a live binding to the C variable of module C, but this is not very easy to describe using normal variable hoisting as in the previous example, and in fact Rollup isn't always handling it properly.

Suppose for example we have module B as the following and modules A and C are the same:

// --- Module Bimport Blah, {initC} from './c';initC();console.log('Module B', Blah)class B extends Blah {    // ...}export {B as default}

Then if we use plain JavaScript to describe only what happens with modules B and C, the result would be like this:

// --- Module BinitC()console.log('Module B', Blah)class B extends Blah {    // ...}// --- Module Cvar Cvar Blah // needs to be addedfunction initC() {    if (C) return    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }    Blah = C // needs to be added}initC()

Another thing to note is that module C also has the initC function call. This is just in case module C is ever evaluated first, it won't hurt to initialize it then.

And the last thing to note is that in these example, modules A and B depend on C at module evaluation time, not at runtime. When modules A and B are evaluated, then require for the C export to be defined. However, when module C is evaluated, it does not depend on A and B imports being defined. Module C will only need to use A and B at runtime in the future, after all modules are evaluated, for example when the entry point runs new A() which will run the C constructor. It is for this reason that module C does not need initA or initB functions.

It is possible that more than one module in a circular dependency need to depend on each other, and in this case a more complex "init function" solution is needed. For example, suppose module C wants to console.log(A) during module evaluation time before class C is defined:

// --- Module Cimport A from './a'import B from './b'var C;console.log(A)export function initC(){    if (C) return;    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }}initC();export {C as default}; // IMPORTANT: not `export default C;` !!

Due to the fact that the entry point in the top example imports A, the C module will be evaluated before the A module. This means that console.log(A) statement at the top of module C will log undefined because class A hasn't been defined yet.

Finally, to make the new example work so that it logs class A instead of undefined, the whole example becomes even more complicated (I've left out module B and the entry point, since those don't change):

// --- Module Aimport C, {initC} from './c';initC();console.log('Module A', C)var Aexport function initA() {    if (A) return    initC()    A = class A extends C {        // ...    }}initA()export {A as default} // IMPORTANT: not `export default A;` !!

-

// --- Module Cimport A, {initA} from './a'import B from './b'initA()var C;console.log(A) // class A, not undefined!export function initC(){    if (C) return;    C = class C {        constructor() {            console.log(A)            console.log(B)        }    }}initC();export {C as default}; // IMPORTANT: not `export default C;` !!

Now, if module B wanted to use A during evaluation time, things would get even more complicated, but I leave that solution for you to imagine...


I would recommend to use inversion of control. Make your C constructor pure by adding an A and a B parameter like this:

// --- Module Aimport C from './C';export default class A extends C {    // ...}// --- Module Bimport C from './C'export default class B extends C {    // ...}// --- Module Cexport default class C {    constructor(A, B) {        // this may run later, after all three modules are evaluated, or        // possibly never.        console.log(A)        console.log(B)    }}// --- Entrypointimport A from './A';import B from './B';import C from './C';const c = new C(A, B);console.log('Entrypoint', C, c);document.getElementById('out').textContent = 'Entrypoint ' + C + ' ' + c;

https://www.webpackbin.com/bins/-KlDeP9Rb60MehsCMa8u

Update, in response to this comment: How to fix this ES6 module circular dependency?

Alternatively, if you do not want the library consumer to know about various implementations, you can either export another function/class that hides those details:

// Module ConcreteCImplementationimport A from './A';import B from './B';import C from './C';export default function () { return new C(A, B); }

or use this pattern:

// --- Module Aimport C, { registerA } from "./C";export default class A extends C {  // ...}registerA(A);// --- Module Bimport C, { registerB } from "./C";export default class B extends C {  // ...}registerB(B);// --- Module Clet A, B;const inheritors = [];export const registerInheritor = inheritor => inheritors.push(inheritor);export const registerA = inheritor => {  registerInheritor(inheritor);  A = inheritor;};export const registerB = inheritor => {  registerInheritor(inheritor);  B = inheritor;};export default class C {  constructor() {    // this may run later, after all three modules are evaluated, or    // possibly never.    console.log(A);    console.log(B);    console.log(inheritors);  }}// --- Entrypointimport A from "./A";import B from "./B";import C from "./C";const c = new C();console.log("Entrypoint", C, c);document.getElementById("out").textContent = "Entrypoint " + C + " " + c;

Update, in response to this comment: How to fix this ES6 module circular dependency?

To allow the end-user to import any subset of the classes, just make a lib.js file exporting the public facing api:

import A from "./A";import B from "./B";import C from "./C";export { A, B, C };

or:

import A from "./A";import B from "./B";import C from "./ConcreteCImplementation";export { A, B, C };

Then you can:

// --- Entrypointimport { C } from "./lib";const c = new C();const output = ["Entrypoint", C, c];console.log.apply(console, output);document.getElementById("out").textContent = output.join();


There is another possible solution..

// --- Entrypointimport A from './app/A'setTimeout(() => console.log('Entrypoint', A), 0)

Yes it's a disgusting hack but it works