JavaScriptのモジュールのimportの循環が必要な場合について

サンプルを https://github.com/metanest/jsMutuallyRecursive に置いてあります。

実行するには、package.json と package-lock.json を同梱してありますので*1、まず npm を次のように実行して必要なモジュールをインストールした後*2

$ npm install .  # 最後の . (ピリオド) を忘れないように

run_webpack.sh を実行すると、webpack が実行されて、ディレクトリ dist1/ と dist2/ の中に NodeJS で実行可能なスクリプトが生成されます。

用語の定義

以降の説明の都合で、JavaScriptスクリプトの実行の流れに関して、少し用語の定義をします。

次のようなスクリプトを実行すると、

console.log(1);
function f() {
  console.log(4);
}
console.log(2);
console.log(3); f(); console.log(5);
console.log(6);

1 から 6 まで順番にコンソールに出力されますが、このような場合の 4 以外の実行の流れを「上から下への実行」、4 の流れを「関数内の実行」と、以下では呼ぶことにします。

循環があっても、関連が疎であり、特に問題が無い場合

src1/ にソースが置いてあり dist1/ に生成されるサンプルは、循環があっても特に問題が無い場合です。

具体的には、mainが次のようで、

import isEven from './isEven';
import isOdd from './isOdd';
console.log(isEven(4));
console.log(isEven(5));
console.log(isOdd(6));
console.log(isOdd(7));

実行結果は次のようになります。

$ node dist1/main.js
This is in isOdd
undefined
========
This is in isEven
[Function]
========
true
false
false
true

また、それぞれの子モジュールの内容は次のようになっているのですが、このように「関数内の実行」からのみの参照であれば問題はありません。undefined が出力されている場所があるように、「上から下への実行」の途中では、import の結果が undefined になっている時はありますが、関数が実行される時には「中身が出来て」います。

// isEven.js
import isOdd from './isOdd';
console.log('This is in isEven');
console.log(isOdd);
console.log('========');
export default x => (x === 0) || isOdd(x-1);
// isOdd.js
import isEven from './isEven';
console.log('This is in isOdd');
console.log(isEven);
console.log('========');
export default x => ! ((x === 0) || ! isEven(x-1));

対称的になっている通り、mainにおける isEven と isOdd のインポートの順序も、どちらでも構いません。

関連が密で、循環による問題がある場合

src2/ にソースが置いてあり dist2/ に生成されるサンプルは、問題がある循環の場合です。

具体的には、mainが次のようで、

import yin from './yin';
import yang from './yang';
yang.yin = yin;
console.log('yin');
console.log(yin);
console.log('========');
console.log('yang');
console.log(yang);
console.log('========');

(このmainにある yang.yin = yin; という代入は、以下のような結果を得るために、どうしても必要)

実行結果は次のようになります。

$ node dist2/main.js
yin
{ yang: { yin: [Circular] } }
========
yang
{ yin: { yang: [Circular] } }
========

各モジュールは次のようになっています。

// yin.js
import yang from './yang';
export default { yang: yang };
// yang.js
import yin from './yin';
export default { yin: yin };

(yinとかyangというのは、古代中国思想の陰陽のことで、米国の古いハッカーが相互再帰的なものを説明する時にたまにこのような名前を使っているのを見ます)

main → yin → yang のようにインポートが進んで、そこからさらに → yin のインポートは循環として検出されて阻止されますから、yangの中での「上から下への実行」の途中では yin は undefined になっていて、先に示したようにmainのほうで補完してやっています(あれこれいじってみましたが、他に方法は無いようでした)。

「上から下への実行」の途中で実際に参照ができる必要がある、現実的なコードの例としては、classのextendsでの親クラスの指定などがあります。

サンプルで意図的にやっているような、双方向に直接の参照が必要な場合は、この例の yang.yin = yin; のように外部から操作して循環を完成させないといけません。一方、例えば親クラスと子クラスの関係のように*3、「上から下への実行」中の参照は片方向で循環しない場合は、この例の yin の側のように、mainで(上位側で)インポートする順番に注意する必要があり、直接に参照している側を先にインポートしてやればうまくいきます。

*1:package-lock.json は、内部に記述されている多数の依存先のバージョンのどれかに脆弱性があると GitHub から警告が来るので外しました。

*2:ピリオドを忘れると、npmの、ディレクトリを遡る特性で変なことになる場合があります。

*3:メソッド中などで必要なために、importは循環になっていても