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

IT技术 javascript module es6-module-loader
2021-02-18 01:28:17

编辑:有关更多背景信息,另请参阅ES Discuss 上的讨论


我有三个moduleABCAB导入从module的默认出口C和moduleC的进口来自默认的AB但是,moduleC不依赖于从module评估导入的值A以及B在module评估期间导入的值,仅在运行时在评估所有三个module之后的某个时间点。moduleAB 确实取决于在Cmodule评估期间导入的值

代码如下所示:

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

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

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

这意味着当 module被求值时Cin module的值因为 module还没有被求值。BBundefinedC

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

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

(我认为 ES6 Module 环境可能能够智能地发现它需要先执行module体,C然后才能执行module体AB。)

6个回答

答案是使用“初始化函数”。作为参考,请查看从这里开始的两条消息: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

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

在这个例子中,入口点从 moduleA导入,从 moduleC导入,从 module 导入B这意味着 moduleB将在 module 之前被评估C,但由于initC从 module导出的函数C被提升的事实, moduleB将被赋予对这个被提升的initC函数的引用,因此评估module之前B调用initCmoduleC

这会导致var Cmodule变量在C定义之前被class B extends C定义。魔法!

需要注意的是,moduleC必须使用var C, not constor let,否则理论上应该在真正的 ES6 环境中抛出时间死区错误。例如,如果module 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;` !!

那么只要moduleB调用initC,就会抛出错误,module评估将失败。

var被提升在 module 的范围内C,所以它在initC被调用时可用这是一个原因,你真的想使用一个很好的例子var,而不是letconst在ES6 +环境。

但是,您可以注意 rollup 无法正确处理此问题https://github.com/rollup/rollup/issues/845,并且看起来let C = C可以在某些环境中使用的 hack 就像上面链接中指出的流星问题。

一个需要注意的最后一个重要的事情是之间的差异export default Cexport {C as default}第一个版本不会Cmodule中变量C作为实时绑定导出,而是按值导出因此,当export default C使用时,var Cisundefined的值将被分配到一个var default隐藏在 ES6 module范围内的新变量上,并且由于C分配到default(如var default = C按值,那么每当module的默认导出C是由另一个module(例如 module B访问,另一个module将进入 moduleC并访问default变量的值,该值始终是undefined。因此,如果 moduleC使用export default C,那么即使moduleB调用initC(它确实改变了 moduleC的内部C变量的值), moduleB实际上不会访问该内部C变量,它将访问default仍然是undefined.

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

我敢打赌,大多数人不会知道export default Cand之间的区别export {C as default},并且在很多情况下他们不需要知道,但是在具有“init 函数”的module之间使用“实时绑定”以解决循环问题时,了解它们之间的区别很重要依赖关系,除此之外,实时绑定可能很有用。不要钻研太远的话题,但是如果您有一个单例,则可以使用活动绑定作为使module范围成为单例对象的一种方式,并且活动绑定是访问来自单例的事物的方式。

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

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

这说明有效地正在发生的事情在ES6module版本:B先进行评估,但var Cfunction initC整个module悬挂,所以moduleB是能够调用initC,然后使用C向右走,之前var Cfunction initC在已评估的代码遇到。

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

假设例如我们有B如下moduleAmodule并且C是相同的:

// --- Module B

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

initC();

console.log('Module B', Blah)

class B extends Blah {
    // ...
}

export {B as default}

然后,如果我们使用纯 JavaScript 仅描述moduleBand发生的事情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()

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

最后要注意的是,在这些示例中,moduleAB依赖C 于module评估时,而不是运行时。当moduleAB被评估时,需要C定义导出。然而,当moduleC进行评估,它不依赖于AB所定义的进口。moduleC将只需要在未来运行时使用AB,在评估所有module后,例如当入口点运行时new A()将运行C构造函数。正是因为这个原因,moduleC不需要initAinitB起作用。

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

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

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

最后,为了让新示例工作以便它记录class A而不是undefined,整个示例变得更加复杂(我已经省略了module 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;` !!

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

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

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

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

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

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

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

要允许最终用户导入类的任何子集,只需创建一个导出面向公众的 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();
就个人而言,我更愿意在合理的范围内保持代码引用透明(无副作用),并导出另一个隐藏该细节的函数/类,如更新的答案。
2021-04-22 01:28:17
太棒了,你注册只是为了回答这个问题。:)
2021-05-01 01:28:17
2021-05-04 01:28:17
谢谢你的建议!这样做的一个问题是,现在您已将依赖项知识从库转移到最终用户,而在这种情况下(无论出于何种原因)可能仅使用 C 的最终用户需要了解 A 和 B,而在此之前只有图书馆作者需要知道。
2021-05-10 01:28:17
这是个好主意,但是在您的示例中,入口点仍然需要导入 A 和 B?理想情况下,最终用户只需要导入正在使用的类,例如仅 A、仅 B 或仅 C,而不是所有三个。
2021-05-17 01:28:17

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

您可以只使用一个主索引,从中导入所有符号。这很简单,JS可以解析它并解决循环导入。有一篇非常好的博客文章描述了这个解决方案,但这里是根据 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(CAB) 中的顺序声明主体中的循环引用可以通过这种方式包含在内。因此,例如,如果 B 和 C 从 A 继承,但 A 的方法包含对 B 或 C 的引用(如果正常导入会引发错误),这将起作用。

是的,JS本身可以很好地解析循环导入(无论是否有主索引module)。真正重要的是评估的顺序 - 您能否将主module如何解决这个问题的解释添加到您的答案中?
2021-04-17 01:28:17
谢谢!好吧,我几乎看不出将 index.js 作为入口点的问题——如果这是您的首选名称,您不妨将其重命名为 A(与评估顺序相同)。坦率地说,考虑到替代方案引入了多少额外的概念(与此相比:仅导入/导出),我认为一点点重构/文件重命名是一个很好的权衡:)
2021-04-26 01:28:17
当然,我认为很明显评估的顺序是 ABC。
2021-04-29 01:28:17
当您index.js用作入口点时 :-) 此外 ABC 是 OP 的错误顺序,OP 需要在class C将其扩展到Aand之前对其进行初始化B
2021-05-15 01:28:17
问题在于导入index.js是如此重要,而忘记执行(或添加与顺序混乱的额外依赖项)会导致难以调试的问题。除了脆弱之外,我自己非常喜欢这种方法。+1!
2021-05-17 01:28:17

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

// --- Entrypoint

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

是的,这是一个令人作呕的黑客,但它有效

您可以通过动态加载module来解决它

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

替换按需导入:

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将不知道在这一点A,并B成为可用的(因为它们是异步加载),所以它的俄罗斯轮盘赌是否游戏C会崩溃与否。
2021-05-02 01:28:17