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

tru*_*ktr 35 javascript module es6-module-loader

编辑:有关更多背景,请参阅ES讨论的讨论.


我有三个模块A,BC.AB导入从模块的默认出口C和模块C导入的默认从两个AB.但是,模块C不依赖于从模块评估期间AB模块评估期间导入的值,仅在运行时在评估所有三个模块之后的某个时间点.模块AB 依赖于进口值C的模块评估期间.

代码看起来像这样:

// --- Module A

import C from 'C'

class A extends C {
    // ...
}

export {A as default}
Run Code Online (Sandbox Code Playgroud)

.

// --- Module B

import C from 'C'

class B extends C {
    // ...
}

export {B as default}
Run Code Online (Sandbox Code Playgroud)

.

// --- 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}
Run Code Online (Sandbox Code Playgroud)

我有以下切入点:

// --- Entrypoint

import A from './app/A'
console.log('Entrypoint', A)
Run Code Online (Sandbox Code Playgroud)

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

Uncaught TypeError: Class extends value undefined is not a function or null
Run Code Online (Sandbox Code Playgroud)

这意味着C在评估模块B时模块B中的值是undefined因为模块C尚未被评估.

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

我的问题是(我可以提出两个具体问题吗?):为什么负载顺序是这样的?如何能圆依赖的模块被写入,使他们的工作,这样的价值C评估时A,并B不会undefined

(我倒觉得ES6模块环境可能能够智能地发现它需要执行模块的身体C,才可以可能执行模块的机构AB.)

tru*_*ktr 14

答案是使用"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}
Run Code Online (Sandbox Code Playgroud)

-

// --- Module B

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

initC();

console.log('Module B', C)

class B extends C {
    // ...
}

export {B as default}
Run Code Online (Sandbox Code Playgroud)

-

// --- 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;` !!
Run Code Online (Sandbox Code Playgroud)

-

// --- Entrypoint

import A from './A'
console.log('Entrypoint', new A) // runs the console.logs in the C
constructor.
Run Code Online (Sandbox Code Playgroud)

另请参阅此主题以获取相关信息:https://github.com/meteor/meteor/issues/7621#issuecomment-238992688

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

在此示例中,入口点从模块A导入,模块从模块导入,从模块C导入B.这意味着模块B将在模块之前进行评估C,但由于模块的导出initC函数C被提升,模块B将被赋予对此提升initC函数的引用,因此在模块评估之前模块B调用调用.initCC

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

重要的是要注意,模块C必须使用var C,否则,const或者let在理论上应该在真正的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;` !!
Run Code Online (Sandbox Code Playgroud)

然后,一旦模块B调用initC,就会抛出错误,模块评估将失败.

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

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

一个需要注意的最后一个重要的事情是之间的差异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 default C,那么即使模块B调用initC(它确实改变了模块的值C) s内部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},而且在许多情况下,他们将不再需要,但为了解决圆形使用"实时绑定"时,跨模块与"初始化函数"来知道其中的差别是非常重要的依赖性,以及活动绑定可能有用的其他内容.不要过分偏离主题,但如果你有一个单例,活动绑定可以用作使模块范围成为单例对象的方法,并且实时绑定从单例访问事物的方式.

描述实时绑定发生的事情的一种方法是编写与上述模块示例类似的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()
Run Code Online (Sandbox Code Playgroud)

这表明有效什么是在ES6模块版本发生在:B先进行评估,但var Cfunction initC整个模块悬挂,所以模块B是能够调用initC,然后使用C向右走,之前var Cfunction initC在已评估的代码遇到.

当然,当模块使用不同的标识符时,它会变得更加复杂,例如,如果模块B具有import Blah from './c',那么Blah仍将是C模块变量的实时绑定C,但是使用常规变量提升并不是很容易描述,如前面的示例所示,事实上Rollup并不总是正确处理它.

例如,假设我们有B以下模块A和模块,C它们是相同的:

// --- Module B

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

initC();

console.log('Module B', Blah)

class B extends Blah {
    // ...
}

export {B as default}
Run Code Online (Sandbox Code Playgroud)

然后,如果我们用普通的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()
Run Code Online (Sandbox Code Playgroud)

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

而且要注意的最后一件事是,在这些例子中,模块AB依赖C 于模块评估时间,不能在运行时.当模块AB被评估,则要求对C来定义导出.然而,当模块C进行评估,它不依赖于AB所定义的进口.模块C只需要使用A并且B在将来运行时,所有模块进行评估之后,例如,当入口点运行new A(),这将运行C构造函数.因此,模块C不需要initAinitB不起作用.

循环依赖中的多个模块可能需要彼此依赖,并且在这种情况下需要更复杂的"初始化函数"解决方案.例如,假设模块C想要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;` !!
Run Code Online (Sandbox Code Playgroud)

由于顶部示例中的入口点导入A,因此C将在A模块之前评估模块.这意味着console.log(A)模块顶部的语句C将记录,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;` !!
Run Code Online (Sandbox Code Playgroud)

-

// --- 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;` !!
Run Code Online (Sandbox Code Playgroud)

现在,如果模块BA在评估期间使用,事情会变得更加复杂,但我留下了解决方案让你想象......

  • 伙计,这太令人困惑了。在模块评估时可见的循环依赖与运行时之间有什么区别?意思是,这种方法的实际优势是什么? (6认同)
  • 好吧,如果你想导出“class A extends C”,那么只需在定义“class A”时评估“C”,因为类不能扩展“undefined”。尝试在控制台中运行“class A extends undefined {}”。 (2认同)

msa*_*and 5

我建议使用控制反转。通过添加 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;
Run Code Online (Sandbox Code Playgroud)

https://www.webpackbin.com/bins/-KlDeP9Rb60MehsCMa8u

更新,响应此评论:如何修复此 ES6 模块循环依赖?

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

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

或使用此模式:

// --- 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;
Run Code Online (Sandbox Code Playgroud)

更新,响应此评论:如何修复此 ES6 模块循环依赖?

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

import A from "./A";
import B from "./B";
import C from "./C";
export { A, B, C };
Run Code Online (Sandbox Code Playgroud)

或者:

import A from "./A";
import B from "./B";
import C from "./ConcreteCImplementation";
export { A, B, C };
Run Code Online (Sandbox Code Playgroud)

然后你可以:

// --- 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();
Run Code Online (Sandbox Code Playgroud)


Gil*_*ili 0

更新:我将把这个答案留给后代,但现在我使用这个解决方案。


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

  1. 将具有循环依赖关系的类分为两部分:触发循环的代码和不触发循环的代码。
  2. 将不触发循环的代码放入“内部”模块中。就我而言,我声明了超类并删除了引用子类的任何方法。
  3. 创建面向公众的模块。
  • import首先是内部模块。
  • import触发依赖循环的模块。
  • 添加回我们在步骤 2 中删除的方法。
  1. 让用户导入面向公众的模块。

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}
Run Code Online (Sandbox Code Playgroud)

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}
Run Code Online (Sandbox Code Playgroud)

所有其他文件保持不变。