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


edit: more background, see discussion on es discuss.


i have 3 modules a, b, , c. a , b import default export module c, , module c imports default both a , b. however, module c not depend on values imported a , b during module evaluation, @ runtime @ point after 3 modules have been evaluated. modules a , b do depend on value imported c during module evaluation.

the code looks this:

// --- module  import c 'c'  class extends c {     // ... }  export {a default} 

.

// --- module b  import c 'c'  class b extends c {     // ... }  export {b default} 

.

// --- module c  import 'a' import b 'b'  class c {     constructor() {         // may run later, after 3 modules evaluated, or         // possibly never.         console.log(a)         console.log(b)     } }  export {c default} 

i have following entry point:

// --- entrypoint  import './app/a' console.log('entrypoint', a) 

but, happens module b evaluated first, , fails error in chrome (using native es6 classes, not transpiling):

uncaught typeerror: class extends value undefined not function or null 

what means value of c in module b when module b being evaluated undefined because module c has not yet been evaluated.

you should able reproduce making 4 files, , running entrypoint file.

my questions (can have 2 concrete questions?): why load order way? how can circularly-dependent modules written work value of c when evaluating a , b not undefined?

(i think es6 module environment may able intelligently discover need execute body of module c before can possibly execute bodies of modules a , b.)

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

the solution looks this:

// --- module  import c, {initc} './c';  initc();  console.log('module a', c)  class extends c {     // ... }  export {a default} 

-

// --- module b  import c, {initc} './c';  initc();  console.log('module b', c)  class b extends c {     // ... }  export {b default} 

-

// --- module c  import './a' import b './b'  var c;  export function initc(){     if (c) return;      c = class c {         constructor() {             console.log(a)             console.log(b)         }     } }  initc();  export {c default}; // important: not `export default c;` !! 

-

// --- entrypoint  import './a' console.log('entrypoint', new a) // runs console.logs in c constructor. 

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

it important note exports hoisted (it may strange, can ask in esdiscuss learn more) var, hoisting happens across modules. classes cannot hoisted, functions can (just in normal pre-es6 scopes, across modules because exports live bindings reach other modules possibly before evaluated, if there scope encompasses modules identifiers can accessed through use of import).

in example, entry point imports module a, imports module c, imports module b. means module b evaluated before module c, due fact exported initc function module c hoisted, module b given reference hoisted initc function, , therefore module b call call initc before module c evaluated.

this causes var c variable of module c become defined prior class b extends c definition. magic!

it important note module c must use var c, not const or let, otherwise temporal deadzone error should theoretically thrown in true es6 environment. example, if module c looked like

// --- module c  import './a' import b './b'  let c;  export function initc(){     if (c) return;      c = class c {         constructor() {             console.log(a)             console.log(b)         }     } }  initc();  export {c default}; // important: not `export default c;` !! 

then module b calls initc, error thrown, , module evaluation fail.

var hoisted within scope of module c, available when initc called. great example of reason why you'd want use var instead of let or const in es6+ environment.

however, can take note rollup doesn't handle correctly https://github.com/rollup/rollup/issues/845, , hack looks let c = c can used in environments pointed out in above link meteor issue.

one last important thing note difference between export default c , export {c default}. first version does not export c variable module c live binding, value. so, when export default c used, value of var c undefined , assigned onto new variable var default hidden inside es6 module scope, , due fact c assigned onto default (as in var default = c value, whenever default export of module c accessed module (for example module b) other module reaching module c , accessing value of default variable going undefined. if module c uses export default c, if module b calls initc (which does change values of module c's internal c variable), module b won't accessing internal c variable, accessing default variable, still undefined.

however, when module c uses form export {c default}, es6 module system uses c variable default exported variable rather making new internal default variable. means c variable live binding. time module depending on module c evaluated, given module c's internal c variable @ given moment, not value, handing on variable other module. so, when module b calls initc, module c's internal c variable gets modified, , module b able use because has reference same variable (even if local identifier different)! basically, time during module evaluation, when module use identifier imported module, module system reaches other module , gets value @ moment in time.

i bet people won't know difference between export default c , export {c default}, , in many cases won't need to, important know difference when using "live bindings" across modules "init functions" in order solve circular dependencies, among other things live bindings can useful. not delve far off topic, if have singleton, alive bindings can used way make module scope singleton object, , live bindings way in things singleton accessed.

one way describe happening live bindings write javascript behave similar above module example. here's modules b , c might in way describes "live bindings":

// --- module b  initc()  console.log('module b', c)  class b extends c {     // ... }  // --- module c  var c  function initc() {     if (c) return      c = class c {         constructor() {             console.log(a)             console.log(b)         }     } }  initc() 

this shows happening in in es6 module version: b evaluated first, var c , function initc hoisted across modules, module b able call initc , use c right away, before var c , function initc encountered in evaluated code.

of course, gets more complicated when modules use differing identifiers, example if module b has import blah './c', blah still live binding c variable of module c, not easy describe using normal variable hoisting in previous example, , in fact rollup isn't handling properly.

suppose example have module b following , modules a , c same:

// --- module b  import blah, {initc} './c';  initc();  console.log('module b', blah)  class b extends blah {     // ... }  export {b default} 

then if use plain javascript describe happens modules b , c, result this:

// --- module b  initc()  console.log('module b', blah)  class b extends blah {     // ... }  // --- module c  var c var blah // needs added  function initc() {     if (c) return      c = class c {         constructor() {             console.log(a)             console.log(b)         }     }     blah = c // needs added }  initc() 

another thing note module c has initc function call. in case module c ever evaluated first, won't hurt initialize then.

and last thing note in these example, modules a , b depend on c at module evaluation time, not @ runtime. when modules a , b evaluated, require c export defined. however, when module c evaluated, not depend on a , b imports being defined. module c need use a , b @ runtime in future, after modules evaluated, example when entry point runs new a() run c constructor. reason module c not need inita or initb functions.

it possible more 1 module in circular dependency need depend on each other, , in case more complex "init function" solution needed. example, suppose module c wants console.log(a) during module evaluation time before class c defined:

// --- module c  import './a' import b './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 default}; // important: not `export default c;` !! 

due fact entry point in top example imports a, c module evaluated before a module. means console.log(a) statement @ top of module c log undefined because class a hasn't been defined yet.

finally, make new example work logs class a instead of undefined, whole example becomes more complicated (i've left out module b , entry point, since don't change):

// --- module  import c, {initc} './c';  initc();  console.log('module a', c)  var  export function inita() {     if (a) return      initc()      = class extends c {         // ...     } }  inita()  export {a default} // important: not `export default a;` !! 

-

// --- module c  import a, {inita} './a' import b './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 default}; // important: not `export default c;` !! 

now, if module b wanted use a during evaluation time, things more complicated, leave solution imagine...


Comments