循環参照でrequireした結果が空オブジェクトになってしまう
webpackなどでCommonJSモジュールを利用していると、モジュール間で循環参照が発生した場合、片方のモジュールでrequire結果が空オブジェクトになってしまう。
例えば下記のような構成で3つのファイルが同じディレクトリにあった場合を考える。
// parent.js
var a = require('./a')
var b = require('./b')
b.printA()
a.printB()
// a.js
var varB = require('./b')
module.exports = {
strA:"moduleA",
printB: function(){ console.log(varB.strB) }
}
// b.js
var varA = require('./a')
module.exports = {
strB:"mobuleB",
printA: function(){ console.log(varA.strA) }
}
// a,bが循環参照
これらのファイルをwebpackでまとめて実行すると、本来はmoduleAと表示されてほしい出力の部分がundefinedになってしまう。
これはb.jsでrequireしたaモジュールが空オブジェクトになっているためだ。
$ webpack parent.js app.js
$ node app.js
undefined
moduleB
本稿では、なぜこれが発生するのか、そしてどういう風に解決すれば良いか考える。
循環参照が空オブジェクトになる理由
CommonJSモジュールのrequireの仕組み
- CommonJSモジュールでは、モジュールがrequireされた際に、module.exportsというオブジェクトが空オブジェクトで初期化される。
- module.exportsに値を加えたり、オブジェクトを置き換えたりすることでそのモジュールをrequireした戻り値としてmodule.exportsの中身を使えるようになる1。
- さらに、CommonJSでは同じファイルを二度requireしようとした場合、二度目はスキップされてその時点で定義されているmodule.exportsの内容を戻り値とする。
- これら2つの仕組みが重なり、循環参照が発生した際、多くの場合では片方のモジュールは空オブジェクトになってしまう
サンプルでの詳しい挙動
このサンプルでの挙動を整理する。
- parentからaがrequireされる
- 制御がa.jsにうつる
- aからbがrequireされる
- 制御がbにうつる
- この段階ではaモジュールは最後まで実行が終わっていないため、aモジュールのmodule.exportsの中身は空オブジェクト
- bにてaがrequireされる
- ここで再び制御がaモジュールの1行目に移るように思えるが、aは既に読み込み済み。そのため制御はbのまま
- varAには現時点でaのmodule.exportsである空オブジェクトが入る
- bでmodule.exportsオブジェクトが書き換わる
- console.log(varA.strA)のクロージャでキャプチャされているvarAは空オブジェクト
- 制御がaにうつる
- aのvarBにbのmodule.exportsが渡される
- bのmodule.exportsは既に書き換わっているため、空オブジェクトではない
- aのmodule.exportsが書き換わる
- 制御がparentに戻る
- parentのaにaモジュールで定義されたmodule.exportsが入る
- aは空オブジェクトではない
- parentのbにbモジュールで定義されたmodule.exportsが入る
- 当然これも空オブジェクトではない
- b.printAが実行される
- b.printAの中で定義されているvarAは空オブジェクトのため、varA.strAはundefinedになる
- a.printBが実行される
- aの中のvarBは空ではないのでmoduleBが出力される
つまりまとめると、モジュールbにおいてはモジュールaがmodule.exportsの内容を更新する前にモジュールaのmodule.exportsの内容を読むことになり、結果として空オブジェクトを取得してしまっている。
また、parent.jsを変更して下記のように1行書き加えてみる。
var a = require('./a')
var b = require('./b')
b.printA()
a.printB()
console.log(a.strA)
この場合の出力は下記のようになる。parentの中でのaはmodule.exportsが書き換わった後の状態のオブジェクトのため、strAが存在する。
undefined
mobuleB
moduleA
循環参照の回避方法
ここで説明するのは、あくまで緊急回避として考えてほしい。循環参照を放置するとメモリリークなどの温床になってしまうからだ。
循環参照が発生するということは、モジュールの設計に問題がある場合が多く、モジュールの切り分けを正しくするのをまずは検討しよう。
どうしても無理だったり、一時的な回避策として以下に記述する解決策を試してみてほしい。
方法1:必要な変数をmodule.exportsに設定した後requireする
a.jsを下記のように書き換えてみよう
module.exports = { strA: "moduleA" }
var varB = require('./b')
module.exports.printB = function(){
console.log(varB.strB)
}
- bモジュールをrequireする前にaモジュールのmodule.exportsのうちbモジュールで使う分を設定してしまうことで、bモジュール内でaのrequire結果を反映できるようにしている。
- お手軽にできるのだが、module.exportsを設定した後にrequireしているため、例えばaでbのメソッドや変数をmodule.exportsの内容に含めたい場合は今回のように後で書き加えることになり、何がexportされているのか分かりにくくなる
方法2:module.exportsの参照を破壊しない
a.jsを下記のように書き換えてみよう
var varB = require('./b')
module.exports.strA = "moduleA"
module.exports.printB = function(){ console.log(varB.strB) }
- a.jsのモジュール開始時に初期化されたmodule.exportsオブジェクトの参照を壊さずにそこに追加していくことで、モジュールbで読み込み時は空オブジェクトだったvarAの中身が、使われる段階では埋まっている。
- この変更だけだとparentで読み込み順を変更してb->aにすると壊れるが、b.jsも同様に変更すれば大丈夫。
- デメリットとしては、実行順に気をつけないとrequireしたオブジェクトが実はまだ空オブジェクトだった、という場合があり分かりにくい恐れがある。
方法3:コンストラクタを作ってその内部で相手に渡す
a.jsとb.jsを下記のように書き換えてみよう
// a.js
var varB = require('./b')
module.exports = new function(){
this.strA = "moduleA"
this.printB = function(){ console.log(varB.strB) }
varB.setA(this)
}
// b.js
module.exports = {
strB: "moduleB",
setA: function(input){ this.a = input },
printA: function(){ console.log(this.a.strA) }
}
- b.jsからはrequire('./a')自体が消えている。
- bの中でのaモジュール内容は、setAによってaモジュール内のコンストラクタで設定されているため、空オブジェクトにはならない。
- 先程の順番入れ替えに比べると、parentでの読み込み順をb->aとしても壊れないので安定性は高い。
しかしa,b両方のモジュールに手を入れる必要があるので、ハードルとしてはやや高い。 - これを応用して、モジュール全体ではなくデータやメソッドの一部だけをsetAの部分で渡すことも可能2。
まとめ
CommonJSモジュールの循環参照が起こってしまう仕組みとその解決方法を示した。どの解決方法をとるかは場合によって変わるかもしれない。
また、繰り返しになるが基本は設計の見直しで対応することが望ましい。
おまけ:循環参照を発見するツール
CommonJSで構成したプロジェクトのモジュール数は数十に及ぶことも珍しくなく、直接の循環参照でなくとも間接的にたどっていくと実は循環していた、ということが多い。
そういった時に一体どれとどれが循環しているのかを見つけるだけでも一苦労なのだが、webpackの場合にサポートしてくれるツールもあるっぽい。