如何修复这个ES6模块循环依赖?

时间:2016-08-09 03:16:26

标签: javascript module es6-module-loader

编辑:有关更多背景信息,请参阅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

这意味着,在评估模块C时,模块BB的值为undefined,因为模块C尚未评价。

您应该可以通过制作这四个文件并运行入口点文件来轻松复制。

我的问题是(我可以提出两个具体问题吗?):为什么负载顺序是这样的?如何编写循环相关的模块,以便它们可以工作,以便在评估CAB的值不是undefined

(我认为ES6模块环境可能能够智能地发现它需要执行模块C的主体才能执行模块的主体A和{{ 1}}。)

6 个答案:

答案 0 :(得分:12)

答案是使用" init函数"。作为参考,请查看从此处开始的两条消息: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

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

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

这会导致模块C的{​​{1}}变量在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时使用。{p> var。这是一个很好的例子,说明您实际上想要在ES6 +环境中使用let而不是constlet C = C

但是,你可以注意汇总不能正确处理https://github.com/rollup/rollup/issues/845,并且可以在某些环境中使用看起来像export default C的黑客,如上面链接指出的那样流星问题。

最后要注意的一点是export {C as default}C之间的区别。第一个版本从模块C导出export default C变量作为实时绑定,但是按值。因此,当使用var C时,undefined的值为var default,并将分配到隐藏在ES6模块范围内的新变量C,并且由于将default分配到var default = C(如C中的值,然后每当模块B的默认导出被另一个模块访问时(例如模块{{ 1}})另一个模块将进入模块C并访问default变量的值,该值始终为undefined。因此,如果模块C使用export default C,即使模块B调用initC 更改模块C内部{{1}的值变量),模块C实际上无法访问该内部B变量,它将访问C变量,该变量仍为default。< / p>

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

我敢打赌,大多数人都不知道Bexport default C之间的区别,并且在许多情况下他们不需要,但重要的是要知道差异时使用&#34;实时绑定&#34;跨越模块的&#34; init函数&#34;为了解决循环依赖性,以及实时绑定可能有用的其他内容。不要过分偏离主题,但如果你有一个单例,可以使用活动绑定作为使模块作用域成为单例对象的方法,并使用实时绑定方式来访问单例中的内容。

描述实时绑定发生的事情的一种方法是编写与上述模块示例类似的javascript。这里有哪些模块export {C as default}B可能以描述&#34;实时绑定&#34;的方式显示:

C

这有效地显示了ES6模块版本中发生的情况:首先评估B,但在模块中提升// --- 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() var C,因此模块function initC可以调用B,然后在评估的代码中遇到initCC之前立即使用var C

当然,当模块使用不同的标识符时会变得更复杂,例如,如果模块function initC具有B,那么import Blah from './c'仍将是Blah的实时绑定模块C的变量,但使用常规变量提升并不是很容易描述,如上例所示,事实上Rollup isn't always handling it properly

假设我们有模块C如下,模块BA是相同的:

C

然后,如果我们使用纯JavaScript来仅描述模块// --- Module B import Blah, {initC} from './c'; initC(); console.log('Module B', Blah) class B extends Blah { // ... } export {B as default} B会发生什么,结果将是这样的:

C

需要注意的另一件事是模块// --- 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首先被评估,然后初始化它不会受到伤害。

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

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

class C

由于顶部示例中的入口点导入// --- 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;` !! A模块将在C模块之前进行评估。这意味着模块A顶部的console.log(A)语句将记录C,因为undefined尚未定义。

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

undefined

-

// --- 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,事情就会变得更加复杂,但我会留下那个解决方案让你想象......

答案 1 :(得分:3)

我建议使用控制反转。通过添加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?

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

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

答案 2 :(得分:1)

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

// --- Entrypoint

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

是的,这是一个令人厌恶的黑客,但它确实有效

答案 3 :(得分:0)

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

  1. 将具有循环依赖关系的类拆分为两部分:触发循环的代码和不循环的代码。
  2. 将不触发循环的代码放入&#34;内部&#34;模块。在我的例子中,我声明了超类并删除了引用子类的任何方法。
  3. 创建面向公众的模块。
      首先
    • import内部模块。
    • import触发依赖循环的模块。
    • 添加我们在步骤2中删除的方法。
  4. 让用户导入面向公众的模块。
  5. 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}
    

    所有其他文件保持不变。

答案 4 :(得分:0)

您可以使用动态加载模块

来解决

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

按需替换导入:

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

狮子座还有另一种解释方法, 仅适用于ECMAScript 2019

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

为了分析循环依赖关系, Artur Hebda 在这里进行解释:

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

答案 5 :(得分:0)

所有先前的答案都有些复杂。应该用“香草”进口产品解决吗?

您可以只使用一个主索引,从中导入所有符号。这很简单,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 A from 'A'
import B from 'B'
import C from 'C'
export {A, B, C}

// --- Entrypoint

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

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