How to solve this basic ES6-module circular dependency problem?
Logan Smyth
loganfsmyth at gmail.com
Wed Aug 24 03:55:02 UTC 2016
> Should I open an issue there? And I think you're right about it should
throw, because it is strange that the function can be executed before the
module is ever evaluated (that seems like it should be impossible), and
therefore a TDZ error would happen because the `let` line wasn't
theoretically evaluated yet.
If you'd like, go for it. To note, Babel 6 doesn't implement TDZ at all at
the moment anyway.
> I don't see how it is possible with `var`. How is it that `var`s or
`function`s can be hoisted *out of* the module? Is that part of spec? If
so, then that is different hoisting from function-based hoisting of pre-ES6.
They are not hoisted out of the module. It seems like you may be
misunderstanding how modules are linked together.
> Pre-ES6 "hoisting" in javascript happens on boundaries set by
`function`s, and I thought that modules would be similar to function
bodies, and therefore I thought that hoisting would be limited to within a
module and did not expect hoisting to go beyond the module boundary to some
scope that encompasses multiple modules. That to me is a different type of
"hoisting" than what I know from pre-ES6 JavaScript's function-scope
hoisting so it isn't necessarily the "same way it'd work with other cases
of hoisting"; there is definitely some ES6-module-specific stuff happening
that is a little different from pre-ES6 function-scope hoisting. (sidenote:
I've asked the awesome Axel of 2ality to add these useful details to his
articles.)
Hoisting of var and functions behaves the same way in ES6 and in ES5 and
you are correct there is no "beyond the module boundary". Let's clarify
hoisting in a function.
```
function fn() {
console.log(inner());
function inner(){ return "hello world"; }
}
fn();
```
in this context, when an engine executes `fn()`, before any execution has
happened inside the `fn`, the engine does the following
1. Create the conceptual function "scope", which exists immediately as soon
as the function is called.
2. Look for all function declarations inside `fn` and create their
variables and assign their values to point to function objects. This is
"function hoisting".
3. Look for all var declarations inside `fn` and create variables with the
value `undefined`. This is "var hoisting".
4. Look for all let/const declarations, and create uninitialized variables
(these will throw when accessed)
5. A bunch of other stuff I'm skipping
6. Execute the function body itself.
A very similar process happens for modules. You can think of it like steps
1-4 running, then before executing the module body (step 6), we recursively
do this same process on every imported module. So by the time any module
gets to step 6, every imported module, and every module those modules
depend on, will have executed step 1-4, and have scopes that have been
created, and variables that have been declared (possibly with a value, or
possibly left uninitialized).
So by the time you get to step 6, there isn't a "beyond the module
boundary", when you access the imported variables in your module, the JS
engine will reach across the module boundary for you, to get the current
value of the variable in the imported module. This behavior of reaching
across module scopes is what module syntax allows, and it is what enables
live binding.
Because of this live behavior, if you imported something that was defined
with `let`, like your `let C;` example, it would cause a TDZ error because
the variable was still "uninitialized", whereas if you make it a `var`, it
will be initialized to `undefined`.
> If I use the `var` method as you proposed (which is working in my Babel
env), should I expect that method to always work in any theoretical
100%-implemented-to-spec ES6 environment, not just in Babel?
Correct.
On Tue, Aug 23, 2016 at 8:17 PM, /#!/JoePea <joe at trusktr.io> wrote:
> > Damn, that's a Babel bug with the block scoping logic. That said, as in
> my example, that needs to be `var C;` anyway, `let` would throw (in an
> environment with working TDZ anyway). Changing it to `var` also stops the
> duplicate printing.
>
> Should I open an issue there? And I think you're right about it should
> throw, because it is strange that the function can be executed before the
> module is ever evaluated (that seems like it should be impossible), and
> therefore a TDZ error would happen because the `let` line wasn't
> theoretically evaluated yet.
>
> I don't see how it is possible with `var`. How is it that `var`s or
> `function`s can be hoisted *out of* the module? Is that part of spec? If
> so, then that is different hoisting from function-based hoisting of pre-ES6.
>
> I was under the impression that the modules were like a function, and
> hoisting would only happen inside the module. In that case, the `initC`
> function could not possibly be available until the `C` module itself was
> evaluated, so I was expecting for there to be an `undefined` error when
> `initC` was called before the `C` module was evaluated.
>
> You say that
>
> > which means you can import and call a function declaration from any
> module, even if that module hasn't started the `Evaluation` phase yet, the
> same way it'd work with other cases of hoisting, where execution hasn't
> reached the function declaration, but it is available early.
>
> Which makes sense based on what I see happening, but it seems strange
> because it means that the scope of the module (as far as hoisting is
> concerned) is not the module itself, but rather some outer scope that wraps
> *all* the modules that import a given symbol.
>
> You said,
>
> > the same way it'd work with other cases of hoisting, where execution
> hasn't reached the function declaration, but it is available early.
>
> Pre-ES6 "hoisting" in javascript happens on boundaries set by `function`s,
> and I thought that modules would be similar to function bodies, and
> therefore I thought that hoisting would be limited to within a module and
> did not expect hoisting to go beyond the module boundary to some scope that
> encompasses multiple modules. That to me is a different type of "hoisting"
> than what I know from pre-ES6 JavaScript's function-scope hoisting so it
> isn't necessarily the "same way it'd work with other cases of hoisting";
> there is definitely some ES6-module-specific stuff happening that is a
> little different from pre-ES6 function-scope hoisting. (sidenote: I've
> asked the awesome Axel of 2ality to add these useful details to his
> articles.)
>
> If I use the `var` method as you proposed (which is working in my Babel
> env), should I expect that method to always work in any theoretical
> 100%-implemented-to-spec ES6 environment, not just in Babel?
>
> If so, then this may be one of the rare cases of "when we'd want to
> actually use `var` instead of `let`" besides for cases when we want pre-ES6
> hoisting which I think should be generally avoided in order to make code
> less error-prone and easier to understand. This behavior would have been
> nearly-impossible to know about without the knowledge gained from this
> conversation (or from reading the spec in depth which can be difficult).
>
>
>
> */#!/*JoePea
>
> On Tue, Aug 16, 2016 at 10:48 AM, Logan Smyth <loganfsmyth at gmail.com>
> wrote:
>
>> > Your `initC` solution is working in Meteor (Babel + Reify) and
>> Webpack+Babel, but the `initC` logic seems to run twice, as if there are
>> two C variables instead of one. The following code is based on yours, and
>> the `console.log('initC!!!')` statement unexpectedly executes twice, and
>> you'll see output like this:
>>
>> Damn, that's a Babel bug with the block scoping logic. That said, as in
>> my example, that needs to be `var C;` anyway, `let` would throw (in an
>> environment with working TDZ anyway). Changing it to `var` also stops the
>> duplicate printing.
>>
>> > I'm also not sure how `initC` can be defined when it is called in the
>> B module, which is evaluated before C and A. The evaluation order is B, C,
>> A (depth first). Does the `initC` function get hoisted into a scope common
>> with all three modules? That is the only way that would seem to explain it,
>> but that seems to go against the intuition I had that each module had it's
>> own module scope (as if it were wrapped inside a `function() {}`, and
>> therefore I thought the `initC` function would be hoisted within the C
>> module, and that with B being evaluated first I thought an "undefined"
>> error would be thrown when it tried to execute `initC` (but that is not
>> happening!). How is it that `initC` can be available to the B module before
>> C is evaluated?
>>
>> There are two separate pieces to executing a module, `Instantiation`, and
>> `Evaluation`, which are what comes into play here. When you tell a JS
>> environment to execute a file, it will instantiate every ES6 module in
>> dependency graph before beginning to execute _any_ of the modules. Babel
>> does its best to simulate this behavior, though it's not perfect at it. One
>> of the things that happens during module instantiation is that hoisted
>> declarations are initialized, which means you can import and call a
>> function declaration from any module, even if that module hasn't started
>> the `Evaluation` phase yet, the same way it'd work with other cases of
>> hoisting, where execution hasn't reached the function declaration, but it
>> is available early.
>>
>> This behavior is why you can't use `let C` there, because when `B` is
>> being evaluated, the `let C` line won't have run because the Evaluation
>> phase of `C` hasn't started yet. You can however access the `initC`
>> function because it is a function declaration. As long as there are no TDZ
>> errors in what you're doing, that function can do whatever it would like,
>> assuming it doesn't depend on other stuff that would require `Evaluation`
>> to have finished in `C`, the same as what happens with hoisting normally.
>> That means for instance you couldn't do
>>
>> import B from './B';
>> var SOME_CONSTANT = "hello";
>>
>> export function initC(){
>> return SOME_CONSTANT;
>> }
>>
>> because calling `initC` here would return `undefined` if called from
>> inside a dependency cycle from `B`.
>>
>> On Sat, Aug 13, 2016 at 9:09 PM, /#!/JoePea <joe at trusktr.io> wrote:
>>
>>> Hi Logan,
>>>
>>> > The example I posted works properly with Babel's live-binding
>>> implementation and should require less repetition. What were your thoughts
>>> on it?
>>>
>>> Your `initC` solution is working in Meteor (Babel + Reify) and
>>> Webpack+Babel, but the `initC` logic seems to run twice, as if there are
>>> two C variables instead of one. The following code is based on yours, and
>>> the `console.log('initC!!!')` statement unexpectedly executes twice, and
>>> you'll see output like this:
>>>
>>> ```
>>> module B
>>> initC!!!
>>> module C
>>> initC!!!
>>> module A
>>> function A() { ... }
>>> function B() { ... }
>>> Entrypoint A {}
>>> ```
>>>
>>> I'm also not sure how `initC` can be defined when it is called in the B
>>> module, which is evaluated before C and A. The evaluation order is B, C, A
>>> (depth first). Does the `initC` function get hoisted into a scope common
>>> with all three modules? That is the only way that would seem to explain it,
>>> but that seems to go against the intuition I had that each module had it's
>>> own module scope (as if it were wrapped inside a `function() {}`, and
>>> therefore I thought the `initC` function would be hoisted within the C
>>> module, and that with B being evaluated first I thought an "undefined"
>>> error would be thrown when it tried to execute `initC` (but that is not
>>> happening!). How is it that `initC` can be available to the B module before
>>> C is evaluated?
>>>
>>> This is the code I have:
>>>
>>> ```js
>>> // --- Entrypoint
>>> import A from './A'
>>> console.log('Entrypoint', new A)
>>> ```
>>>
>>> ```js
>>> // --- Module A
>>>
>>> import C, {initC} from './C'
>>>
>>> console.log('module A')
>>> initC()
>>>
>>> class A extends C {
>>> // ...
>>> }
>>>
>>> export {A as default}
>>> ```
>>>
>>> ```js
>>> // --- Module B
>>>
>>> import C, {initC} from './C'
>>>
>>> console.log('module B')
>>> initC()
>>>
>>> class B extends C {
>>> // ...
>>> }
>>>
>>> export {B as default}
>>> ```
>>>
>>> ```js
>>> // --- Module C
>>>
>>> import A from './A'
>>> import B from './B'
>>>
>>> console.log('module C')
>>> let C
>>>
>>> export function initC(){
>>> if (C) return
>>>
>>> console.log('initC!!!')
>>>
>>> C = class C {
>>> constructor() {
>>> // this may run later, after all three modules are
>>> evaluated, or
>>> // possibly never.
>>> console.log(A)
>>> console.log(B)
>>> }
>>> }
>>> }
>>>
>>> initC()
>>>
>>> export {C as default}
>>> ```
>>>
>>> */#!/*JoePea
>>>
>>> On Thu, Aug 11, 2016 at 10:26 AM, Logan Smyth <loganfsmyth at gmail.com>
>>> wrote:
>>>
>>>> Keep in mind `let A = A;` is a TDZ error in any real ES6 environment.
>>>>
>>>> The example I posted works properly with Babel's live-binding
>>>> implementation and should require less repetition. What were your thoughts
>>>> on it?
>>>>
>>>> On Thu, Aug 11, 2016 at 12:23 AM, /#!/JoePea <joe at trusktr.io> wrote:
>>>>
>>>>> Alright, so I believe I have found the solution. It is not possible to
>>>>> guarantee a certain module evaluation order, but using some clever (but
>>>>> tedious) conditional checking I believe the problem is solved (with two
>>>>> caveats listed after):
>>>>>
>>>>> ```js
>>>>> // --- Entrypoint
>>>>> import A from './A'
>>>>> console.log('Entrypoint', new A)
>>>>> ```
>>>>>
>>>>> ```js
>>>>> // --- Module A
>>>>>
>>>>> import C from './C'
>>>>> import {setUpB} from './B'
>>>>>
>>>>> let A
>>>>>
>>>>> export
>>>>> function setUpA(C) {
>>>>>
>>>>> if (!A) {
>>>>> A = class A extends C {
>>>>> // ...
>>>>> }
>>>>> }
>>>>>
>>>>> }
>>>>>
>>>>> if (setUpA && C) setUpA(C)
>>>>> if (setUpB && C) setUpB(C)
>>>>>
>>>>> export {A as default}
>>>>> ```
>>>>>
>>>>> ```js
>>>>> // --- Module B
>>>>>
>>>>> import C from './C'
>>>>> import {setUpA} from './A'
>>>>>
>>>>> let B
>>>>>
>>>>> export
>>>>> function setUpB(C) {
>>>>>
>>>>> if (!B) {
>>>>> B = class B extends C {
>>>>> // ...
>>>>> }
>>>>> }
>>>>>
>>>>> }
>>>>>
>>>>> if (setUpA && C) setUpA(C)
>>>>> if (setUpB && C) setUpB(C)
>>>>>
>>>>> export {B as default}
>>>>> ```
>>>>>
>>>>> ```js
>>>>> // --- Module C
>>>>>
>>>>> import A, {setUpA} from './A'
>>>>> import B, {setUpB} from './B'
>>>>>
>>>>> class C {
>>>>> constructor() {
>>>>> // this may run later, after all three modules are evaluated,
>>>>> or
>>>>> // possibly never.
>>>>> console.log(A)
>>>>> console.log(B)
>>>>> }
>>>>> }
>>>>>
>>>>> if (setUpA && C) setUpA(C)
>>>>> if (setUpB && C) setUpB(C)
>>>>>
>>>>> export {C as default}
>>>>> ```
>>>>>
>>>>> The caveat is that this fails in both Babel environments and in
>>>>> Rollup. For it to work in Babel environments, `let A` and `let B` have to
>>>>> be changed to `let A = A` and `let B = B`, as per the [fault in Babel's
>>>>> ES2015-to-CommonJS implementation](https://github
>>>>> .com/meteor/meteor/issues/7621#issuecomment-238992688) pointed out by
>>>>> Ben Newman. And it fails in Rollup because Rollup [isn't really creating
>>>>> live bindings](https://github.com/rollup/rollup/issues/845), which is
>>>>> fine in most cases, but doesn't work with these circular dependencies. The
>>>>> Rollup output does not create the C reference before it is ever used in the
>>>>> first pair of conditional checks, unlike what (I think) would happen with
>>>>> real live bindings (please correct me if wrong). To understand what I mean
>>>>> in the case of Rollup, just run `if (FOO) console.log(FOO)` in your
>>>>> console, and you'll get an error because FOO is not defined. Had the
>>>>> bindings been live, then FOO *would* be defined.
>>>>>
>>>>>
>>>>>
>>>>> */#!/*JoePea
>>>>>
>>>>> On Wed, Aug 10, 2016 at 5:04 PM, /#!/JoePea <joe at trusktr.io> wrote:
>>>>>
>>>>>> I found a solution that works in environments compiled by Babel,
>>>>>> using the [workaround suggested by Ben Newman](https://github.com/met
>>>>>> eor/meteor/issues/7621#issuecomment-238992688):
>>>>>>
>>>>>> ```js
>>>>>> // --- Module A
>>>>>>
>>>>>> import C from './C'
>>>>>>
>>>>>> let A = A // @benjamn's workaround applied
>>>>>>
>>>>>> export
>>>>>> function setUpA(C) {
>>>>>>
>>>>>> A = class A extends C {
>>>>>> // ...
>>>>>> }
>>>>>>
>>>>>> }
>>>>>>
>>>>>> export {A as default}
>>>>>> ```
>>>>>>
>>>>>> ```js
>>>>>> // --- Module B
>>>>>>
>>>>>> import C from './C'
>>>>>>
>>>>>> let B = B // @benjamn's workaround applied
>>>>>>
>>>>>> export
>>>>>> function setUpB(C) {
>>>>>>
>>>>>> B = class B extends C {
>>>>>> // ...
>>>>>> }
>>>>>>
>>>>>> }
>>>>>>
>>>>>> export {B as default}
>>>>>> ```
>>>>>>
>>>>>> ```js
>>>>>> // --- Module C
>>>>>>
>>>>>> import A, {setUpA} from './A'
>>>>>> import B, {setUpB} from './B'
>>>>>>
>>>>>> let C = class C {
>>>>>> constructor() {
>>>>>> // this may run later, after all three modules are evaluated,
>>>>>> or
>>>>>> // possibly never.
>>>>>> console.log(A)
>>>>>> console.log(B)
>>>>>> }
>>>>>> }
>>>>>>
>>>>>> setUpA(C)
>>>>>> setUpB(C)
>>>>>>
>>>>>> export {C as default}
>>>>>> ```
>>>>>>
>>>>>> ```js
>>>>>> // --- Entrypoint
>>>>>>
>>>>>> import A from './A'
>>>>>> console.log('Entrypoint', new A) // runs the console.logs in the C
>>>>>> constructor.
>>>>>> ```
>>>>>>
>>>>>>
>>>>>> Although that works in my environment which is compiled from ES6
>>>>>> modules to CommonJS by Babel, it [doesn't work in Rollup.js](
>>>>>> http://goo.gl/PXXBKI), and may not work in other ES6 module
>>>>>> implementations.
>>>>>>
>>>>>> Is there some solution that will theoretically work in any ES6 module
>>>>>> environment?
>>>>>>
>>>>>> */#!/*JoePea
>>>>>>
>>>>>
>>>>>
>>>>> _______________________________________________
>>>>> es-discuss mailing list
>>>>> es-discuss at mozilla.org
>>>>> https://mail.mozilla.org/listinfo/es-discuss
>>>>>
>>>>>
>>>>
>>>
>>
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://mail.mozilla.org/pipermail/es-discuss/attachments/20160823/58972455/attachment-0001.html>
More information about the es-discuss
mailing list