「循環依存関係」とは、スクリプト a の実行はスクリプト b に依存し、スクリプト b の実行はスクリプト a に依存することを意味します。
// a.js var b = require('b'); // b.js var a = require('a');
通常、「ループ読み込み」は強い結合が存在することを示します。適切に処理しないと、再帰読み込みが発生してプログラムが実行できなくなる可能性があるため、回避する必要があります。
しかし実際には、これを避けるのは難しく、特に複雑な依存関係を持つ大規模なプロジェクトでは、a が b に依存し、b が c に依存し、c が a に依存することが容易になります。これは、モジュール読み込みメカニズムが「ループ読み込み」状況を考慮する必要があることを意味します。
この記事では、JavaScript 言語が「ループ読み込み」を処理する方法を紹介します。現在、最も一般的な 2 つのモジュール形式である CommonJS と ES6 は、処理方法が異なり、異なる結果を返します。
1. CommonJS モジュールの読み込み原理
ES6 が「ループ読み込み」をどのように処理するかを紹介する前に、まず最も一般的な CommonJS モジュール形式の読み込み原理を紹介しましょう。
CommonJSのモジュールはスクリプトファイルです。 require コマンドが初めてスクリプトをロードするとき、スクリプト全体が実行され、メモリ内にオブジェクトが生成されます。
{ id: '...', exports: { ... }, loaded: true, ... }
上記のコードでは、オブジェクトの id 属性はモジュール名、exports 属性はモジュールによって出力される各インターフェイス、loaded 属性はモジュールのスクリプトが実行されたかどうかを示すブール値です。他にもたくさんの属性がありますが、ここでは省略します。 (詳細な導入については、「require() ソース コードの解釈」を参照してください。)
今後このモジュールを使用する必要がある場合は、exports 属性から値を取得します。 requireコマンドを再度実行しても、モジュールは再度実行されませんが、値はキャッシュから取得されます。
2. CommonJS モジュールのループ読み込み
CommonJS モジュールの重要な機能は、ロード時の実行です。つまり、すべてのスクリプト コードが必要に応じて実行されます。 CommonJS のアプローチは、モジュールが「ループロード」されると、実行された部分のみが出力され、未実行の部分は出力されないというものです。
公式ドキュメントの例を見てみましょう。スクリプトファイルa.jsのコードは以下のとおりです。
exports.done = false; var b = require('./b.js'); console.log('在 a.js 之中,b.done = %j', b.done); exports.done = true; console.log('a.js 执行完毕');
上記のコードでは、a.js スクリプトは最初に Done 変数を出力し、次に別のスクリプト ファイル b.js を読み込みます。この時点で a.js コードはここで停止し、b.js の実行が完了するのを待ってから実行を続行することに注意してください。
b.js コードをもう一度見てください。
exports.done = false; var a = require('./a.js'); console.log('在 b.js 之中,a.done = %j', a.done); exports.done = true; console.log('b.js 执行完毕');
上記のコードでは、b.jsを2行目まで実行すると、a.jsが読み込まれます。このとき「ループロード」が発生します。システムは、a.js モジュールに対応するオブジェクトの exports 属性の値を取得します。ただし、a.js はまだ実行されていないため、exports 属性から取得できるのは実行された部分だけであり、最終的な値は取得できません。
a.js の実行部分は 1 行だけです。
exports.done = false;
したがって、b.js の場合、a.js から行われた変数を 1 つだけ入力し、値は false になります。
その後、b.js は実行を継続し、すべての実行が完了すると、実行権が a.js に戻ります。したがって、a.js は実行が完了するまで実行を続けます。このプロセスを検証するスクリプト main.js を作成します。
var a = require('./a.js'); var b = require('./b.js'); console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
main.jsを実行すると以下のようになります。
$ node main.js 在 b.js 之中,a.done = false b.js 执行完毕 在 a.js 之中,b.done = true a.js 执行完毕 在 main.js 之中, a.done=true, b.done=true
上記のコードは 2 つのことを証明しています。まず、b.jsではa.jsは実行されておらず、最初の行だけが実行されています。次に、main.js を 2 行目まで実行すると、再び b.js が実行されるのではなく、キャッシュされた b.js の実行結果、つまり 4 行目が出力されます。
exports.done = true;
3. ES6 モジュールのループロード
ES6 モジュールの動作メカニズムは CommonJS とは異なります。モジュール読み込みコマンドのインポートに遭遇した場合、モジュールは実行されず、参照が生成されるだけです。本当に使用する必要があるまで待ってから、モジュール内の値を取得します。
したがって、ES6 モジュールは動的参照であり、値のキャッシュの問題はなく、モジュール内の変数は、それらが配置されているモジュールにバインドされます。以下の例を参照してください。
// m1.js export var foo = 'bar'; setTimeout(() => foo = 'baz', 500); // m2.js import {foo} from './m1.js'; console.log(foo); setTimeout(() => console.log(foo), 500);
上記のコードでは、最初にロードされたとき、m1.js の変数 foo は bar と等しくなりますが、500 ミリ秒後には再び baz と等しくなります。
m2.js がこの変更を正しく読み取れるかどうかを確認してみましょう。
$ babel-node m2.js bar baz
上記のコードは、ES6 モジュールが実行結果をキャッシュせず、ロードされたモジュールの値を動的に取得し、変数が常にその変数が配置されているモジュールにバインドされていることを示しています。
これにより、ES6 は CommonJS とは本質的に異なる方法で「ループ読み込み」を処理します。 ES6 は「ループ読み込み」が発生するかどうかをまったく気にせず、ロードされたモジュールへの参照を生成するだけです。開発者は、値が実際に取得されるときにその値が取得できることを確認する必要があります。
次の例を参照してください (Axel Rauschmayer 博士による「Exploring ES6」からの抜粋)。
// a.js import {bar} from './b.js'; export function foo() { bar(); console.log('执行完毕'); } foo(); // b.js import {foo} from './a.js'; export function bar() { if (Math.random() > 0.5) { foo(); } }
按照CommonJS规范,上面的代码是没法执行的。a先加载b,然后b又加载a,这时a还没有任何执行结果,所以输出结果为null,即对于b.js来说,变量foo的值等于null,后面的foo()就会报错。
但是,ES6可以执行上面的代码。
$ babel-node a.js
执行完毕
a.js之所以能够执行,原因就在于ES6加载的变量,都是动态引用其所在的模块。只要引用是存在的,代码就能执行。
我们再来看ES6模块加载器SystemJS给出的一个例子。
// even.js import { odd } from './odd' export var counter = 0; export function even(n) { counter++; return n == 0 || odd(n - 1); } // odd.js import { even } from './even'; export function odd(n) { return n != 0 && even(n - 1); }
上面代码中,even.js里面的函数foo有一个参数n,只要不等于0,就会减去1,传入加载的odd()。odd.js也会做类似操作。
运行上面这段代码,结果如下。
$ babel-node > import * as m from './even.js'; > m.even(10); true > m.counter 6 > m.even(20) true > m.counter 17
上面代码中,参数n从10变为0的过程中,foo()一共会执行6次,所以变量counter等于6。第二次调用even()时,参数n从20变为0,foo()一共会执行11次,加上前面的6次,所以变量counter等于17。
这个例子要是改写成CommonJS,就根本无法执行,会报错。
// even.js var odd = require('./odd'); var counter = 0; exports.counter = counter; exports.even = function(n) { counter++; return n == 0 || odd(n - 1); } // odd.js var even = require('./even').even; module.exports = function(n) { return n != 0 && even(n - 1); }
上面代码中,even.js加载odd.js,而odd.js又去加载even.js,形成"循环加载"。这时,执行引擎就会输出even.js已经执行的部分(不存在任何结果),所以在odd.js之中,变量even等于null,等到后面调用even(n-1)就会报错。
$ node > var m = require('./even'); > m.even(10) TypeError: even is not a function
[说明] 本文是我写的《ECMAScript 6入门》第20章《Module》中的一节。