【问题标题】:How to fix this ES6 module circular dependency?如何修复这个 ES6 模块循环依赖?
【发布时间】:2016-12-14 22:51:40
【问题描述】:

编辑:有关更多背景信息,另请参阅discussion on ES Discuss


我有三个模块ABCAB 从模块 C 导入默认导出,而模块 CAB 导入默认导出。但是,模块C 不依赖于在模块评估期间从AB 导入的值,仅在运行时在评估所有三个模块之后的某个时间点。模块 AB 确实 在其模块评估期间依赖于从 C 导入的值。

代码如下所示:

// --- 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}

我有以下入口点:

// --- Entrypoint

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

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

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

这意味着当模块B被评估时,模块BC的值是undefined,因为模块C还没有被评估。

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

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

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

【问题讨论】:

  • 啊,想把这个作为一个规范的问题很久了,看看我什么时候有时间回答所有问题
  • 乔,我看到你在esdiscuss.org/topic/… 发布了一个解决方案,但我不明白CircularDepNonCircularDep 指的是什么。对我来说,问题中的所有模块都包含某种形式的循环依赖。您能否按照本问题的定义以ABC 的形式发布答案?
  • @Gili 嘿,如果你能在那个帖子里回复,那就太好了。我认为您只需发送一封主题相同的电子邮件即可。

标签: javascript module es6-module-loader


【解决方案1】:

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

解决方案如下:

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

相关信息请参见此主题:https://github.com/meteor/meteor/issues/7621#issuecomment-238992688

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

在此示例中,入口点从模块 A 导入,该模块从模块 C 导入,该模块从模块 B 导入。这意味着模块B 将在模块C 之前被评估,但是由于从模块C 导出的initC 函数被提升,模块B 将被引用这个提升的initC函数,因此在评估模块 C 之前,模块 B 调用 call initC

这会导致模块Cvar C 变量在class B extends C 定义之前定义。魔法!

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

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

那么一旦模块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 并将分配给隐藏在 ES6 模块范围内的新变量 var default,并且由于分配了 C 的事实到default(如var default = C中的值,那么每当模块C的默认导出被另一个模块(例如模块B)访问时,另一个模块将进入模块C并访问default 变量的值总是undefined。所以如果模块C 使用export default C,那么即使模块B 调用initC(这确实改变模块C的内部C变量的值),模块B实际上不会访问该内部C变量,它将访问default变量,它仍然是undefined

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

我敢打赌,大多数人不会知道 export default Cexport {C as default} 之间的区别,而且在许多情况下他们也不需要知道,但是在跨模块使用“实时绑定”时了解区别很重要“init 函数”以解决循环依赖关系,其中实时绑定可能很有用。不要深入研究主题,但如果你有一个单例,可以使用活动绑定作为使模块范围成为单例对象的一种方式,并且可以使用活动绑定来访问单例中的内容。

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

// --- 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()

这有效地显示了 ES6 模块版本中发生的情况:首先评估 B,但 var Cfunction initC 在模块之间提升,因此模块 B 能够调用 initC 然后使用C 立即,在评估代码中遇到 var Cfunction initC 之前。

当然,当模块使用不同的标识符时,它会变得更加复杂,例如,如果模块B 具有import Blah from './c',那么Blah 仍将是与模块CC 变量的实时绑定C,但这并不像前面的例子那样使用普通的变量提升来描述,实际上是Rollup isn't always handling it properly

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

// --- Module B

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

initC();

console.log('Module B', Blah)

class B extends Blah {
    // ...
}

export {B as default}

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

// --- 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()

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

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

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

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

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

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

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

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

【讨论】:

  • 伙计,这太令人困惑了。在模块评估时可见的循环依赖与运行时有什么区别?意思是,这种方法的实际优势是什么?
  • 好吧,如果你想导出class A extends C,那么C只需要在定义class A时进行评估,因为类不能扩展undefined。尝试在控制台中运行 class A extends undefined {}
  • 评估模块时需要C依赖,否则A将扩展undefined并抛出错误。运行时的依赖意味着直到将来某个时候才需要依赖,例如,模块 A 的用户在将来的某个时候调用new A,或者可能永远不会调用它。如果用户从不调用new A,那么console.log 语句将永远不会运行。因此,运行时依赖项是在评估模块后的某个时间点使用的依赖项,并且可能永远不会使用它们。明白我的意思吗?
  • 另一种思考方式是“运行时”是评估入口点模块的时间。此时,入口点代码将运行(所有其他模块都已被评估)。那是运行时。此外,入口点可以延迟逻辑以触发用户事件、超时或其他将来触发的代码,在模块被评估很久之后。
  • 例如,如果new A 在一小时后被触发,那么到那时所有模块都已被评估,因此依赖项必须存在(除非模块是以某种奇怪的方式编写的,需要运行时调用模块初始化导出的代码)。
【解决方案2】:

我建议使用控制反转。通过像这样添加一个 A 和一个 B 参数来使你的 C 构造函数变得纯粹:

// --- Module A

import C from './C';

export default class A extends C {
    // ...
}

// --- Module B

import C from './C'

export default class B extends C {
    // ...
}

// --- Module C

export 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)
    }
}

// --- Entrypoint

import 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

更新,回应此评论:How to fix this ES6 module circular dependency?

或者,如果您不希望库使用者了解各种实现,您可以导出另一个隐藏这些细节的函数/类:

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

或者使用这个模式:

// --- Module A

import C, { registerA } from "./C";

export default class A extends C {
  // ...
}

registerA(A);

// --- Module B

import C, { registerB } from "./C";

export default class B extends C {
  // ...
}

registerB(B);

// --- Module C

let 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);
  }
}

// --- Entrypoint

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

更新,回应此评论:How to fix this ES6 module circular dependency?

要允许最终用户导入类的任何子集,只需制作一个导出面向公众的 api 的 lib.js 文件:

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

或:

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

那么你可以:

// --- Entrypoint

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

【讨论】:

  • 感谢您的建议!这样做的一个问题是,现在您已将依赖关系知识从库转移到最终用户,并且在这种情况下(无论出于何种原因)可能仅使用 C 的最终用户将需要了解 A 和 B,之前在哪里只有库作者需要知道。
  • 太棒了,您注册只是为了回答这个问题。 :)
  • 那么这个怎么样? webpackbin.com/bins/-Kl_37vgaKD3saNUXqQo
  • 就个人而言,我希望尽可能合理地保留代码referentially transparent(无副作用),并导出另一个隐藏该细节的函数/类,如更新的答案中所示。跨度>
  • 这是个好主意,但是在您的示例中,入口点仍然需要导入 A 和 B?理想情况下,最终用户只需要导入正在使用的类,例如仅 A、仅 B 或仅 C,但不是全部三个。
【解决方案3】:

前面所有的答案都有点复杂。这不应该用“香草”进口来解决吗?

您可以只使用一个主索引,从中导入所有符号。这很简单,JS 可以解析它并解决循环导入。有一个 really nice blog post 描述了这个解决方案,但这里是根据 OP 的问题:

// --- Module A

import C from './index.js'
...

// --- Module B

import C from './index.js'
...

// --- Module C

import {A, B} from './index.js'
...

// --- index.js
import C from 'C'
import A from 'A'
import B from 'B'
export {A, B, C}

// --- Entrypoint

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

评估顺序为index.js (C-A-B) 中的顺序。可以通过这种方式在声明主体中包含循环引用。因此,例如,如果 B 和 C 继承自 A,但 A 的方法包含对 B 或 C 的引用(如果正常导入会引发错误),这将起作用。

【讨论】:

  • 是的,JS 可以解析原生循环导入就好了(不管是否有主索引模块)。真正重要的是评估顺序 - 您能否将主模块如何解决该问题的解释添加到您的答案中?
  • 当然,我认为评估顺序很明显是 A-B-C。
  • 如果您使用 index.js 作为入口点 :-) 此外 ABC 对 OP 来说是错误的顺序,他需要在之前初始化 class CAB 中扩展它。
  • 谢谢!好吧,我几乎看不到将 index.js 作为入口点的问题——如果这是您的首选名称,您不妨将其重命名为 A(同样具有评估顺序)。坦率地说,考虑到替代品引入了多少额外的概念(与此相比:仅导入/导出),我认为一点点重构/文件重命名是一个很好的权衡:)
  • 问题在于导入index.js 非常重要,而忘记执行它(或添加额外的依赖项会影响顺序)会导致难以调试的问题。除了脆弱之外,我自己也非常喜欢这种方法。 +1!
【解决方案4】:

还有另一种可能的解决方案..

// --- Entrypoint

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

是的,这是一个令人作呕的 hack,但它确实有效

【讨论】:

    【解决方案5】:

    您可以通过动态加载模块

    来解决它

    我有同样的问题,我只是动态导入模块。

    替换按需导入:

    import module from 'module-path';
    

    动态导入:

    let module;
    import('module-path').then((res)=>{
        module = res;
    });
    

    在您的示例中,您应该像这样更改 c.js

    import C from './internal/c'
    let A;
    let B;
    import('./a').then((res)=>{
        A = res;
    });
    import('./b').then((res)=>{
        B = res;
    });
    
    // See http://stackoverflow.com/a/9267343/14731 for why we can't replace "C.prototype.constructor"
    let temp = C.prototype;
    C = function() {
      // this may run later, after all three modules are evaluated, or
      // possibly never.
      console.log(A)
      console.log(B)
    }
    C.prototype = temp;
    
    export {C as default}
    

    有关动态导入的更多信息:

    http://2ality.com/2017/01/import-operator.html

    leo 解释了另一种方式,仅适用于 ECMAScript 2019

    https://stackoverflow.com/a/40418615/1972338

    为了分析循环依赖,Artur Hebda在这里解释一下:

    https://railsware.com/blog/2018/06/27/how-to-analyze-circular-dependencies-in-es6/

    【讨论】:

    • 这是非常有问题的,因为导入c.js 的代码不知道AB 将在什么时候可用(因为它们是异步加载的),所以这是一个俄语游戏轮盘赌C 是否会崩溃。
    【解决方案6】:

    这是一个对我有用的简单解决方案。我最初尝试了trusktr's approach,但它触发了奇怪的 eslint 和 IntelliJ IDEA 警告(他们声称该类没有声明)。以下解决方案很好,因为它消除了依赖循环。没有魔法。

    1. 将具有循环依赖关系的类分成两部分:触发循环的代码和不触发循环的代码。
    2. 将不触发循环的代码放入“内部”模块。在我的例子中,我声明了超类并删除了所有引用子类的方法。
    3. 创建一个面向公众的模块。
      • import 内部模块优先。
      • import 触发依赖循环的模块。
      • 重新添加我们在第 2 步中删除的方法。
    4. 让用户导入面向公众的模块。

    OP 的示例有点做作,因为在第 3 步中添加构造函数比添加普通方法要困难得多,但总体概念保持不变。

    内部/c.js

    // Notice, we avoid importing any dependencies that could trigger loops.
    // Importing external dependencies or internal dependencies that we know
    // are safe is fine.
    
    class C {
        // OP's class didn't have any methods that didn't trigger
        // a loop, but if it did, you'd declare them here.
    }
    
    export {C as default}
    

    c.js

    import C from './internal/c'
    // NOTE: We must import './internal/c' first!
    import A from 'A'
    import B from 'B'
    
    // See http://stackoverflow.com/a/9267343/14731 for why we can't replace
    // "C.prototype.constructor" directly.
    let temp = C.prototype;
    C = function() {
      // this may run later, after all three modules are evaluated, or
      // possibly never.
      console.log(A)
      console.log(B)
    }
    C.prototype = temp;
    
    // For normal methods, simply include:
    // C.prototype.strippedMethod = function() {...}
    
    export {C as default}
    

    所有其他文件保持不变。

    【讨论】:

    • 在您的示例中,C 仅在运行时依赖 A 和 B,但在模块评估时不依赖。如果您希望 C 在评估时依赖 A 和 B 怎么办?这就是我遇到的问题。例如,假设我们想要class C extends (A.name === 'A' ? Foo : Bar) {}。这纯粹是假设性的,想象一些构建步骤将A 替换为基于谁知道什么的不同定义。重点是C类只能根据A的值来定义。
    • esdiscuss 线程中的“init 函数”是解决该问题的一种方法。如果没有这些,那么由于 A 依赖于 C,而 C 依赖于 A,一个模块的评估将失败,C 或 A 未定义,具体取决于评估模块的顺序。
    • @trusktr 您能否发布一个单独的答案来充实“初始化函数”解决方案在这种情况下的样子?我不明白。
    • 发表了一个答案,这样解释更好吗?它是否回答了您关于CircularDepNonCircularDep 的问题?
    • @trusktr 你问过:What if you want C to depend on A and B at evaluation time? 这个答案显示A 在模块评估时取决于C,而C 在运行时取决于A。您是否在模块评估时要求A 依赖CC 依赖A both?我认为这在技术上是不可能的。
    猜你喜欢
    • 2018-03-17
    • 1970-01-01
    • 2014-04-28
    • 1970-01-01
    • 2018-09-19
    • 2017-10-27
    • 2021-09-18
    • 2014-04-15
    • 1970-01-01
    相关资源
    最近更新 更多