ブラウザ側でのrequire.js小話。
グローバル汚染とjsファイルの参照関係をいい感じに解決してくれるrequire.js。htmlファイルにscriptタグでjsファイルを書きまくる開発なんて今となってはもう考えられない。

そんなrequire.jsだけども、ちょっとややこしいのが循環参照の解決方法。
そもそもjavascriptに限らず循環参照させること自体間違っているんだという主張はもちろん一理ある。require.js公式の循環参照に関する説明文にもこんなことが書いてある。

Circular dependencies are rare, and usually a sign that you might want to rethink the design.

はい、ぐうの音も出ません。でも、でもでもどうしても循環参照が必要となる場面は出てきてしまうのである。雨が降ればキノコが生えてくるように、コードが増えればやつらも生えてくるのである。

いろいろ試してハマってまた試してを繰り返してやっと循環参照の挙動が見えてきたので、その全容をここに残しておきたい。

コードがあったほうがイメージしやすいのでまずはまるで意図したかのような循環参照を作り出す。

main.js

// data-mainで呼び出すメイン処理
define(function(require) {  
  // log.jsを参照
  var log = require("log");
  log.print();
});

log.js

// ログモジュール
define(function(require) {  
  // message.jsを参照
  var message = require("message");

  var log = {
    print : function() {
      console.log(message.getMessage());
    },
    header : "log: ",
  };

  return log;
});

message.js

// メッセージモジュール
define(function(require) {  
  // log.jsを参照
  var log = require("log");

  var message = {
    getMessage : function() {
      return log.header + "hello"
    }
  };

  return message;
});

処理内容の意味はともかくとして、log.jsmessage.js間での循環参照が完成した。
実際にmain.jsdata-mainに書いて実行してみると分かるが、以下のようなエラーが出てこのコードは動かない。

MODULE NAME ... HAS NOT BEEN LOADED YET FOR CONTEXT: ...

何が起きているかを簡単に説明すると、
mainを読み込みたい
mainにはlogが必要だから先に読み込みたい
logにはmessageが必要だから先に読み込みたい
logが必要だから先に読み込みたい
→循環参照のせいで読み込む順番が決定しないから読み込めない

この場合はmessage.jsを次のようにすれば解決する。

message.js(改)

// メッセージモジュール
define(function(require) {  
  var message = {
    getMessage : function() {
      // function内(defineのトップレベルではない場所)でlog.jsを参照
      var log = require("log");
      return log.header + "hello"
    }
  };

  return message;
});

重要なのは、後から呼ばれる側が、defineのトップレベルではない場所requireを行うこと。ここテストに出ます。

require.jsでは、defineのトップレベルrequireした場合とそうでない場合でモジュールロードの挙動が異なっている。
少しややこしいが、トップレベルというのはdefineの引数となっているfunction直下のこと。あるいはそのモジュールがreturnされるまでに実行される部分と言い換えてもいいはず。

トップレベルに書かれているrequireは自分がロードされたときに参照しているモジュールとしてロード対象となる。
先の例では、logモジュールがトップレベルでmessagerequireしているのが正にその状況。

一方でトップレベルに書かれていないrequireは、すでにロードされているモジュールから対象のモジュールを取得する。ここもテストに出ます。

そう、一見同じことをしているように見えるが、requireトップレベルに書かれているか否かで全く異なる処理を行っているのである。

なんとなくrequire.jsを使い始めたけれど、やっとこの挙動を理解することができた。でも今の段階で気づけて良かった、本当に良かった。

ここまでの説明でmessage.jsではエラーとなり、message.js(改)ではエラーとならない理由がお分かり頂けただろうか。
message.js(改)のように書くことで、log.jsからrequireされた段階ではmessage.js(改)log.jsを参照しているとは見なされないのでロードに成功する。
そしてgetMessageを実行した時にはロード済のモジュールからlogを探して取得するのである。

先ほど【後から呼ばれる側】がトップレベル以外の場所でrequireする必要があると書いた理由もここにある。
トップレベル以外のrequireではロードを行わないのである。
実際にmain.jsを次のように書いてみるとその挙動がよく分かる。

main.js(改)

// data-mainで呼び出すメイン処理
define(function(require) {  
  // message.jsを参照
  var message = require("message");
});

message.jsは(改)になっているとする。
このmainが実行されると、例のロードされていませんエラーが発生する。

おや?と思った人は勘がいい。そう、logrequireを実行する関数であるgetMessageが実行されていないのにエラーが発生する。
ここが最後の1癖である。この1癖を間違えたら問答無用で0点なので心してもらいたい。

トップレベル以外に書かれたrequire対象モジュールが全てロード済でなければ、そのモジュールはロードできない。

つまり、このmainを実行するためには次のようにしなければならない。

main.js(改2)

// data-mainで呼び出すメイン処理
define(function(require) {  
  // logを参照
  require("log");
  // message.jsを参照
  var message = require("message");
});

循環参照エラーを解決しようと試せば試すほどrequire.jsが分からなくなる泥沼だったが、ここまで理解してしまえば今までの挙動にも全て納得が得られる。

トップレベル以外に書かれたrequire対象モジュールが全てロード済でなければ、そのモジュールはロードできない。

ここまで書いてから気づいたが、この説明は少しおかしい。だってlogをロードする時にmessageをロードするのだから、logのロードはまだ完了していないじゃないかと。訂正するなら次のような感じだろうか。

トップレベル以外に書かれたrequire対象モジュールが全てロード済、あるいはロード中のキューに入っていなければ、そのモジュールはロードできない。

もっと簡単に書くと、

トップレベル以外に書かれた全てのrequire対象モジュールが既にどこかのトップレベルでrequireされていなければ、そのモジュールはロードできない。

うん、この言い回しが一番しっくりくる。
循環参照これにて解決。


ここからは本流からは逸れるがさらに掘り進んだお話。
掘り下げるのはこの部分。

トップレベル以外に書かれたrequire対象モジュールが全てロード済でなければ、そのモジュールはロードできない。

実はこれ、require文字列リテラルを直接指定した場合の挙動であることが分かった。
例えばmessage.js(改)をこんな風にしてみる。

message.js(改2)

// メッセージモジュール
define(function(require) {  
  var message = {
    getMessage : function() {
      // 動的な変数でlog.jsを参照
      var name = "log";
      var log = require(name);
      return log.header + "hello"
    }
  };

  return message;
});

この状態で先ほどのmain.js(改)の方を実行してみると、なんとエラーにならない。
もちろんgetMessageを実行した場合はエラーになる。

どうやらトップレベル以外のrequire対象モジュールが全てロード済かの判定は、文字列リテラルで指定されているrequireのみに対して行っているらしい。
そして変数で指定されているモジュールのロード済判定は、実際にそのrequireが実行された時に行われている。
この挙動はデバッグのステップ実行でも追える。

まだ必要のない関数内で行われているrequire対象モジュールも全て予めロード必須な文字列リテラルよりも、本当にそのrequireが行われるタイミングで対象モジュールがロードされていればよい変数指定の方が直感的で使いやすそうなのはきっと気のせいではないはず。

なんでもrequire.jsは基本的に文字列解析でrequireを実現しているらしいので、その影響だろう。

おそらくこの変数requireの挙動はトップレベル以外で行ったrequire特有のものではない。
変数での動的requireはできないものと考えていたが、動的にロードされ得るモジュールが全てロード済であれば実現できるのである。

require.js、まだまだ深い。