如何修复这个 ES6 模块循环依赖? [英] How to fix this ES6 module circular dependency?

查看:37
本文介绍了如何修复这个 ES6 模块循环依赖?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

有关更多背景信息,另请参阅讨论ES 讨论.

<小时>

我有三个模块ABC.ABC 模块导入默认导出,C 模块从 导入默认导出AB.但是,模块 C 在模块评估期间不依赖于从 AB 导入的值,仅在运行时在所有三个模块之后的某个时刻已被评估.模块 AB do 依赖于在模块评估期间从 C 导入的值.

代码如下所示:

//--- 模块 A从'C'导入CA类扩展C{//...}导出 {A 作为默认值}

.

//--- 模块 B从'C'导入CB 类扩展 C {//...}导出 {B 作为默认值}

.

//--- 模块 C从'A'导入A从'B'导入BC类{构造函数(){//这可能会在评估所有三个模块之后运行,或者//可能永远不会.控制台日志(A)控制台日志(B)}}导出 {C 作为默认值}

我有以下入口点:

//--- 入口点从 './app/A' 导入 Aconsole.log('入口点', A)

但是,实际发生的情况是首先评估模块 B,它在 Chrome 中失败并出现此错误(使用原生 ES6 类,而不是转译):

Uncaught TypeError: Class extends value undefined is not a function or null

这意味着当模块B被求值时B模块中C的值是undefined 因为模块 C 还没有被评估.

通过制作这四个文件并运行入口点文件,您应该能够轻松重现.

我的问题是(我可以问两个具体的问题吗?):为什么加载顺序是这样的?如何编写循环依赖的模块,以便它们可以工作,以便在评估 ABC 的值不会是 <代码>未定义?

(我认为 ES6 Module 环境可能会智能地发现它需要先执行模块 C 的主体,然后才能执行模块 AB.)

解决方案

答案是使用init 函数".作为参考,请查看从这里开始的两条消息:https://esdiscuss.org/topic/how-to-solve-this-basic-es6-module-circular-dependency-problem#content-21

解决方案如下:

//--- 模块 A导入 C, {initC} from './c';initC();console.log('模块 A', C)A类扩展C{//...}导出 {A 作为默认值}

-

//--- 模块 B导入 C, {initC} from './c';initC();console.log('模块 B', C)B 类扩展 C {//...}导出 {B 作为默认值}

-

//--- 模块 C从 './a' 导入 A从 './b' 导入 B变量 C;导出函数 initC(){如果 (C) 返回;C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}}initC();导出 {C 作为默认值};//重要:不是`export default C;` !!

-

//--- 入口点从'./A'导入Aconsole.log('Entrypoint', new A)//在 C 中运行 console.logs构造函数.

另请参阅此线程以获取相关信息:https://github.com/流星/流星/问题/7621#issuecomment-238992688

需要注意的是,exports 就像 var 一样被提升(可能很奇怪,你可以在 esdiscuss 中询问以了解更多信息),但提升是跨模块发生的.类不能被提升,但函数可以(就像它们在 ES6 之前的正常范围内,但跨模块,因为导出是实时绑定,可能在它们被评估之前到达其他模块,几乎好像有一个范围包含所有标识符只能通过使用 import 访问的模块).

在这个例子中,入口点从模块 A 导入,从模块 C 导入,从模块 B 导入.这意味着模块 B 将在模块 C 之前被评估,但由于从模块 C 导出的 initC 函数code> 被提升,模块 B 将被赋予这个被提升的 initC 函数的引用,因此模块 B call call initC 在模块 C 被评估之前.

这会导致 C 模块的 var C 变量在 class B extends C 定义之前被定义.魔法!

需要注意的是模块C必须使用var C,而不是constlet,否则理论上应该在真正的 ES6 环境中抛出时间死区错误.例如,如果模块 C 看起来像

//--- 模块 C从 './a' 导入 A从 './b' 导入 B让 C;导出函数 initC(){如果 (C) 返回;C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}}initC();导出 {C 作为默认值};//重要:不是`export default C;` !!

那么一旦模块B调用initC,就会抛出一个错误,模块评估就会失败.

var 被提升到C 模块的作用域内,因此在initC 被调用时可用.这是一个很好的例子,说明了在 ES6+ 环境中您实际上想要使用 var 而不是 letconst 的原因.>

但是,您可以注意 rollup 无法正确处理此问题 https://github.com/rollup/rollup/issues/845,还有一个看起来像 let C = C 的 hack 可以在一些环境中使用,就像上面的 Meteor 问题链接中指出的那样.

最后一件需要注意的重要事情是export default Cexport {C as default} 之间的区别.第一个版本C 模块中的C 变量作为实时绑定导出,而是通过值导出.因此,当使用 export default C 时,var C 的值为 undefined 并将分配给一个新变量 var default 隐藏在 ES6 模块范围内,并且由于 C 被分配到 default(如 var default = C 按值,然后每当模块 C 的默认导出被另一个模块(例如模块 B)访问时,另一个模块将访问模块 C 并访问 default 变量的值,该变量总是 undefined.所以如果模块 C 使用 export默认 C,那么即使模块 B 调用 initC(这确实改变了模块 C 的值> 的内部 C 变量),模块 B 实际上不会访问该内部 C 变量,它将访问 默认 variable,它仍然是undefined.

然而,当模块C使用export {C as default}形式时,ES6模块系统默认使用C变量导出变量而不是创建一个新的内部 default 变量.这意味着 C 变量是一个实时绑定.任何时候依赖于模块 C 的模块被评估,它都会在给定时刻被赋予模块 C 的内部 C 变量,而不是按值,但几乎就像将变量交给另一个模块一样.所以,当B 模块调用initC 时,C 模块的内部C 变量被修改, 模块>B 之所以能够使用它,是因为它引用了同一个变量(即使本地标识符不同)!基本上,在模块评估期间的任何时候,当一个模块将使用它从另一个模块导入的标识符时,模块系统就会进入另一个模块并及时获取该值.

我敢打赌大多数人不会知道 export default Cexport {C as default} 之间的区别,而且在很多情况下他们不需要,但重要的是要了解在具有初始化函数"的模块之间使用实时绑定"以解决循环依赖问题时的区别,以及实时绑定可能有用的其他事项.不要钻研太远的话题,但如果您有一个单例,则可以使用活动绑定作为使模块范围成为单例对象的一种方式,并且活动绑定是访问来自单例的事物的方式.

描述实时绑定发生的事情的一种方法是编写 javascript,其行为类似于上述模块示例.以下是模块 BC 在描述实时绑定"时的样子:

//--- 模块 BinitC()console.log('模块 B', C)B 类扩展 C {//...}//--- 模块 C变量 C函数 initC() {如果 (C) 返回C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}}initC()

这有效地显示了 ES6 模块版本中发生的事情:B 首先被评估,但是 var Cfunction initC 在模块之间被提升,所以 module B 能够调用 initC 然后立即使用 C,在 var Cfunction initC<之前/code> 在评估代码中遇到.

当然,当模块使用不同的标识符时会变得更复杂,例如如果模块 Bimport Blah from './c',那么 Blah 仍将是对 C 模块的 C 变量的实时绑定,但这不像前面的例子那样使用普通的变量提升来描述,事实上Rollup 并不总是正确处理它.

假设例如我们有模块 B 如下,模块 AC 是相同的:

//--- 模块 B从'./c'导入废话,{initC};initC();console.log('模块 B', 废话)B 类扩展了 Blah {//...}导出 {B 作为默认值}

那么如果我们使用纯 JavaScript 只描述模块 BC 发生的事情,结果将是这样的:

//--- 模块 BinitC()console.log('模块 B', 废话)B 类扩展了 Blah {//...}//--- 模块 C变量 Cvar Blah//需要添加函数 initC() {如果 (C) 返回C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}Blah = C//需要添加}initC()

另一件需要注意的是C 模块也有initC 函数调用.这是以防万一 C 模块首先被评估,然后初始化它不会有什么坏处.

最后要注意的是,在这些示例中,模块 AB 在模块评估时依赖于 C ,不是在运行时.当模块 AB 被评估时,需要定义 C 导出.但是,当评估模块 C 时,它不依赖于定义的 AB 导入.模块 C 将在以后的运行时只需要使用 AB,在所有模块都被评估之后,例如当入口点运行时new A() 将运行 C 构造函数.正是因为这个原因,C 模块不需要 initAinitB 函数.

循环依赖中可能有多个模块需要相互依赖,在这种情况下,需要一个更复杂的init函数"解决方案.例如,假设 C 模块想要在 class C 定义之前的模块评估期间console.log(A):

//--- 模块 C从 './a' 导入 A从 './b' 导入 B变量 C;控制台日志(A)导出函数 initC(){如果 (C) 返回;C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}}initC();导出 {C 作为默认值};//重要:不是`export default C;` !!

由于上面示例中的入口点导入了 AC 模块将在 A 模块之前进行评估.这意味着 C 模块顶部的 console.log(A) 语句将记录 undefined 因为 class A尚未定义.

最后,为了使新示例工作,以便记录 class A 而不是 undefined,整个示例变得更加复杂(我省略了模块 B和入口点,因为它们不会改变):

//--- 模块 A导入 C, {initC} from './c';initC();console.log('模块 A', C)变种A导出函数 initA() {如果(A)返回initC()A = A 类扩展 C {//...}}initA()export {A as default}//重要:不是`export default A;` !!

-

//--- 模块 C从 './a' 导入 A, {initA}从 './b' 导入 BinitA()变量 C;console.log(A)//类 A,不是未定义的!导出函数 initC(){如果 (C) 返回;C = 类 C {构造函数(){控制台日志(A)控制台日志(B)}}}initC();导出 {C 作为默认值};//重要:不是`export default C;` !!

现在,如果模块 B 想在评估期间使用 A,事情会变得更加复杂,但我把这个解决方案留给你想象......

EDIT: for more background, also see the discussion on ES Discuss.


I have three modules A, B, and C. A and B import the default export from module C, and module C imports the default from both A and B. However, module C does not depend on the values imported from A and B during module evaluation, only at runtime at some point after all three modules have been evaluated. Modules A and B do depend on the value imported from C during their module evaluation.

The code looks something like this:

// --- Module A

import C from 'C'

class A extends C {
    // ...
}

export {A as default}

.

// --- Module B

import C from 'C'

class B extends C {
    // ...
}

export {B as default}

.

// --- Module C

import A from 'A'
import B from 'B'

class C {
    constructor() {
        // this may run later, after all three modules are evaluated, or
        // possibly never.
        console.log(A)
        console.log(B)
    }
}

export {C as default}

I have the following entry point:

// --- Entrypoint

import A from './app/A'
console.log('Entrypoint', A)

But, what actually happens is that module B is evaluated first, and it fails with this error in Chrome (using native ES6 classes, not transpiling):

Uncaught TypeError: Class extends value undefined is not a function or null

What that means is that the value of C in module B when module B is being evaluated is undefined because module C has not yet been evaluated.

You should be able to easily reproduce by making those four files, and running the entrypoint file.

My questions are (can I have two concrete questions?): Why is the load order that way? How can the circularly-dependent modules be written so that they will work so that the value of C when evaluating A and B will not be undefined?

(I would think that the ES6 Module environment may be able to intelligently discover that it will need to execute the body of module C before it can possibly execute the bodies of modules A and B.)

解决方案

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 A

import C, {initC} from './c';

initC();

console.log('Module A', C)

class A extends C {
    // ...
}

export {A as default}

-

// --- Module B

import C, {initC} from './c';

initC();

console.log('Module B', C)

class B extends C {
    // ...
}

export {B as default}

-

// --- Module C

import 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;` !!

-

// --- Entrypoint

import A from './A'
console.log('Entrypoint', new A) // runs the console.logs in the C
constructor.

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 C

import 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 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 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 B

import 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 B

initC()

console.log('Module B', Blah)

class B extends Blah {
    // ...
}

// --- Module C

var C
var Blah // needs to be added

function 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 C

import 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 A

import C, {initC} from './c';

initC();

console.log('Module A', C)

var A

export function initA() {
    if (A) return

    initC()

    A = class A extends C {
        // ...
    }
}

initA()

export {A as default} // IMPORTANT: not `export default A;` !!

-

// --- Module C

import 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...

这篇关于如何修复这个 ES6 模块循环依赖?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆