RequireJSの挙動と既存のページにjQueryが使われているときの問題点と対策のメモです。
jQueryのAMD対応
まずはじめに、AMDのモジュールを作るときは、requireで読み込むならrequireで読み込む、exposeするならexposeするという形でユニバーサルなモジュール設計にすることができます。例えば以下です。
if (typeof define == 'function' && typeof define.amd == 'object'){
// AMD
}else{
// Expose
}
これに対して、jQueryのような汎用的(かつすでに幅広く使われている)なライブラリの場合だと、やっぱりそういう訳にはいかないみたいで、
window.jQuery = window.$ = jQuery;
//省略
if ( typeof define === "function" && define.amd && define.amd.jQuery ) {
define( "jquery", [], function () { return jQuery; } );
}
上記のように、グローバル(window)へのexposeと、defineの両方を行っているようです。
また、lodashのコードを見てみても同じように、defineする前にグローバルにexposeしています。
つまり、モジュールローダ経由で読み込んだ場合でも、モジュールとして読み込まれると同時にグローバルにも追加されるような形になります。
またjQueryはdefineするときに、モジュールIDを設定しています。(第1引数)。ここがまたハマりどころでした。
jQueryのカスタムビルドを利用して、このexpose部分を外せればいいのですが、AMDモジュールは外せてもこの部分は外せない(?)っぽいので、何かしらの対策をしなくてはいけません。
noConflictを使った対策
読み込みのタイミングで必ずグローバルにexposeしてしまう対策方法のひとつにnoConflictを使う方法があります。
jQueryは初期化時に、window.jQuery(それとwindow.$) があるかどうかをチェックしているようで、内部的にそれを保持しているようです。
jQuery.noConflictというメソッドはグローバルオブジェクトにある$オブジェクト(引数をtrueにするとwindow.jQueryも)を初期化時に内部的に保持した以前のwindow.jQueryの参照に置き換えるメソッドのようなのですがこれを使うことでグローバルオブジェクトからjQueryの参照を置き換えたり、無くすことができます。
RequireJSの公式サイトで紹介されている方法は、jquery-privateという、内部的にはjQuery.noConflict(true)を返すだけのファイルを作ってそれをjQuery依存のモジュールで必ず呼ぶというものです。
このように対策すれば全然おっけーと思っていたのですが、意外とハマりどころがあるようです。
パターン1 : すでにjQueryが読み込まれている場所への追加読み込み
既存のページにすでにjQueryが読み込まれているページへ、RequireJSを使用して追加でモジュールを読み込む時のパターンです。
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
//jquery-1.10.2.js 200 OK
console.log('>> script maybe 1.10.2');
console.log('>> script',typeof $); // function
console.log('>> script',window.$().jquery); // 1.10.2
console.log('>> script',$().jquery === '1.10.2'); //true
})();
</script>
<script src="/js/lib/require.js"></script>
<script>
(function(){
require.config({
paths : {
'jquery' : '/js/lib/jquery-1.9.1'
}
});
require(['jquery'],function(_$){
//jquery-1.9.1.js 200 OK
console.log('>> require maybe 1.9.1');
console.log('>> require',typeof _$); // function
console.log('>> require',_$().jquery); // 1.10.2
console.log('>> require',_$().jquery === '1.9.1'); //false
// ここでnoConfrictを実行する
console.log('>> require before noConflict',window.$().jquery); // 1.9.1
_$.noConflict();
console.log('>> require after noConflict',window.$().jquery); // 1.10.2
});
})();
</script>
わかりやすさを重視して、モジュール内に渡ってくるjQueryは_$としています。
上記の例の場合、まずjQuery1.10.2を読み込んだあとに1.9.1が読み込まれています。
その後、モジュール内でnoConflictを実行することで、モジュール内のjQueryは1.9.1を保ちつつ、グローバルのjQueryは1.10.2に戻ります。(万々歳)
パターン2 : 読み込む順番が逆のパターン
ハマりやすいのがパターン1の読み込む順番が逆のパターンです。
具体的にはRequireJSの処理の記述のほうが、scriptタグで読み込む記述よりも前にある場合です。
<script src="/js/lib/require.js"></script>
<script>
(function(){
require.config({
paths : {
'jquery' : '/js/lib/jquery-1.9.1'
}
});
require(['jquery'],function(_$){
//jquery-1.9.1.js が読み込まれていないがコールバックは実行
console.log('>> require maybe 1.9.1');
console.log('>> require',typeof _$); // function
console.log('>> require',_$().jquery); // 1.10.2
console.log('>> require',_$().jquery === '1.9.1'); //false
// ここでnoConfrictを実行する
console.log('>> require before noConflict',window.$().jquery); // 1.10.2
_$.noConflict();
console.log('>> require after noConflict',window.$().jquery); // Uncaught TypeError requireでjQueryが読み込まれないためにグローバルからすべてのjQueryオブジェクトが削除された
});
})();
</script>
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
//jquery-1.10.2.js 200 OK
console.log('>> script maybe 1.10.2');
console.log('>> script',typeof $); // function
console.log('>> script',window.$().jquery); // 1.10.2
console.log('>> script',$().jquery === '1.10.2'); //true
})();
</script>
このソースの期待された挙動は、まず1.10.2をグローバルにexposeしたのちに、RequireJSで1.9.1を読み込み、noConflictを使ってグローバルのバージョンを1.10.2に戻したいというものです。
しかし、実際には1.9.1は読み込まれず、グローバルのjQueryオブジェクトも変更されません。
ChromeのDeveloper ToolsのNetworkタブを見ても、読み込まれているのは1.10.2のみです。
変わりにモジュール内に渡ってきたjQueryのバージョンは1.10.2です。
1.10.2のjQueryは最初に読み込まれたものなので、内部で既存のjQueryを保持していません。(undefinedを保持)
この状態でnoConflictを実行するとグローバルのjQueryにはundefinedが代入されてしまい、.jquery
メソッドを呼び出そうとするタイミングで以下のように怒られてしまいます。
Uncaught TypeError: Property '$' of object [object global] is not a function
これでは万々歳ではありません。(Not 万々歳)
パターン3 : パターン2でがんばってjQueryの読み込みをさせようと試みるパターン
jQueryはdefineするときにモジュールIDをつけていました。
読み込む順番的には、require.js → jquery-1.10.2.js と読み込まれていて、このタイミングでモジュール(モジュールID)が登録されたのではないか?、、、一度読み込まれたモジュールにモジュールIDがある場合、同じモジュールIDを持つモジュールは読み込みをしないのではないか?、、、と推測してみたところで、別の試みをしてみます。
次の例は、require.configのpathsでモジュールIDを変更したものを読み込んでいるものです。
<script src="/js/lib/require.js"></script>
<script>
(function(){
require.config({
paths : {
'jquery1.9.1' : '/js/lib/jquery-1.9.1'
}
});
require(['jquery1.9.1'],function(_$){
//jquery-1.9.1.js 200 OK
console.log('>> require maybe 1.9.1');
console.log('>> require',typeof _$); // undefined jQueryのmodule idと呼び出し元のmodule idが違うため引数が渡されない
console.log('>> require',_$().jquery); // Uncaught TypeError
console.log('>> require',_$().jquery === '1.9.1'); // Not called
// ここでnoConfrictを実行する
console.log('>> require before noConflict',window.$().jquery); // Not called
_$.noConflict(); // Not called
console.log('>> require after noConflict',window.$().jquery); // Not called
});
})();
</script>
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
//jquery-1.10.2.js 200 OK
console.log('>> script maybe 1.10.2');
console.log('>> script',typeof $); // function
console.log('>> script',window.$().jquery); // 1.10.2
console.log('>> script',$().jquery === '1.10.2'); //true
})();
</script>
モジュールIDを変更したおかげか1.9.1も読み込まれています。
しかし、今度は肝心の読み込もうとしたjQueryが渡ってきません。
defineで定義したモジュールIDとrequire.configのモジュールIDが違う場合、コールバックにはモジュールが渡ってこないようです。(Not 万々歳)
パターン4 : 駄目そうだけど、依存ファイルに直接打ち込んでみる
パターン3とほぼ同じですが、依存ファイルを記述する配列に直接モジュールID(パスからjsを抜いたもの)を記述したパターンです。
<script src="/js/lib/require.js"></script>
<script>
(function(){
require(['/js/lib/jquery-1.9.1.js'],function(_$){
console.log('>> require maybe 1.9.1');
console.log('>> require',typeof _$); // undefined jQueryのmodule idと呼び出し元のmodule idが違うため引数が渡されない
console.log('>> require',_$().jquery); // Uncaught TypeError
console.log('>> require',_$().jquery === '1.9.1'); // Not called
// ここでnoConfrictを実行する
console.log('>> require before noConflict',window.$().jquery); // Not called
_$.noConflict(); // Not called
console.log('>> require after noConflict',window.$().jquery); // Not called
});
})();
</script>
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
console.log('>> script maybe 1.10.2');
console.log('>> script',typeof $); // function
console.log('>> script',window.$().jquery); // 1.10.2
console.log('>> script',$().jquery === '1.10.2'); //true
})();
</script>
案の定、パターン3と同じく、コールバックにモジュールが渡ってきません。当然、Not 万々歳です。(Not 万々歳)
パターン5 : configにcontextを設定する
結論からいうと、これらの問題はrequire.configでcontextを指定することで解決できます。
<script src="/js/lib/require.js"></script>
<script>
(function(){
var someModuleCtx = require.config({
paths : {
'jquery' : '/js/lib/jquery-1.9.1'
},
context: "someModule"
});
someModuleCtx(['jquery'],function(_$){
console.log('>> require maybe 1.9.1');
console.log('>> require',typeof _$); // function
console.log('>> require',_$().jquery); // 1.9.1
console.log('>> require',_$().jquery === '1.9.1'); // true
// ここでnoConfrictを実行する
console.log('>> require before noConflict',window.$().jquery); // 1.9.1
_$.noConflict(); // Not called
console.log('>> require after noConflict',window.$().jquery); // 1.10.2
});
})();
</script>
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
console.log('>> script maybe 1.10.2');
console.log('>> script',typeof $); // function
console.log('>> script',window.$().jquery); // 1.10.2
console.log('>> script',$().jquery === '1.10.2'); //true
})();
</script>
こうすることで別コンテキストとなり、無事あとのバージョンのjQueryも読み込まれました。
しっかりとバージョン別のjQueryが読み込まれたので、noConflictを実行したあとにも前のバージョンのjQueryがグローバルに残っています。
require.configの戻り値を受け取らなくてはいけないのがちょっとアレですが、いちよ解決しました。(万々歳)
パターン6 : RequireJS自体の読み込みの間に別バージョンのjQueryが混ざっている場合
RequireJSの読み込み直後に別バージョンのjQueryを同期読み込みしている場合。
正直、ここまで来ると、なんとかして読み込み側のコードの読み込み位置を調整したいところなのですが、、、
無理矢理対応する場合、contextオプションをつけたrequire.configを2回呼ぶことで解決します。
<script src="/js/lib/require.js"></script>
<script src="/js/lib/jquery-1.10.2.js"></script>
<script>
(function(){
require.config({context : 'dummy'}); // 一度require.configを呼ぶ
var someModuleCtx = require.config({
paths : {
jquery : '/js/lib/jquery-1.9.1'
},
context : 'someModule'
});
someModuleCtx(['jquery'],function(_$){
var _requiredJQ;
console.log('>> require maybe 1.9.1');
console.log('>> require window.$',window.$().jquery); // 1.9.1
console.log('>> require _$',_$().jquery); // 1.9.1
_$.noConflict(true);
console.log('>> require after process window.$',window.$().jquery); // 1.10.2
console.log('>> require after process _$',_$().jquery); // 1.9.1
});
})();
</script>
上記の場合、require.jsを読み込んだ後、jQueryを同期的に読み込んでいるので、jquery-1.10.2が登録されてします。
はじめにrequire.configをcontextオプションをつけてコールします。ここで返ってくるのは1.10.2を登録したコンテキストです。
その後、もう一度require.configをコールし、そこで返されたコンテキストでモジュールを読み込むことで、モジュールで読み込む側のバージョンのjQueryも読み込まれます。
と、がんばってみたものの、すでにあるコードの中に今回の例のようなモジュールの入れ方をすることは、そこまでないのではないかとは思います。(だけど万々歳)
パターン7 : 別バージョンのjQueryが同期的な読み込みではないパターン
色々なページに共通モジュールとして組み込む場合、組み込み先のページ次第では挙動が変わってしまうかもしれません。
この様な例もめったにないと思うのですが、共通モジュールが読み込まれる前後に非同期的にjQueryが読み込まれる場合を考えてみます。
<script>
(function(d){
setTimeout(function(){
var s = d.createElement('script'),
scripts = d.getElementsByTagName('script');
s.src = '/js/lib/jquery-1.10.2.js';
scripts[0].parentNode.insertBefore(s,scripts[0]);
},10); //変動
})(document);
</script>
<script src="/js/lib/require.js"></script>
<script>
(function(){
var someModuleCtx = require.config({
paths : {
'jquery' : '/js/lib/jquery-1.9.1'
},
context : 'someModule'
});
someModuleCtx(['jquery'],function(_$){
var _requiredJQ;
console.log('>> require maybe 1.9.1');
console.log('>> require global',window.$().jquery); // 1.9.1
console.log('>> require _$',_$().jquery); // 1.10.2 or 1.9.1
// 非同期的な読み込みを想定して、ちょっと遅らせてチェック
setTimeout(function(){
console.log('>> require timeout window.$',window.$().jquery); // 1.10.2 or 1.9.1
},100);
});
})();
</script>
この場合ですと非同期的に読み込まれるもののタイミング次第でその後のグローバルのjQueryやモジュールに読み込まれるjQueryのバージョンが変動してしまいます。
これは、上記のコードでいうところのsetTimeoutのディレイ秒次第で変わってしまうことになります。
その場合、共通モジュールとして複数のページに入れようとしている場合、noConflictをするべきかしないべきかの判断がつけずらくなります。(Not 万々歳)
パターン8 : ガチ・チェックパターン
パターン7のように、どのタイミングで既存のjQueryが読み込まれるか分からない場合には、仕方ないのでガチチェックすることで回避します。
<script type="text/javascript">
(function(){
var __winJQ = window.jQuery;
require.config({context : 'dummy'});
var someModule = require.config({
paths : {
'jquery' : '/js/lib/jquery-1.9.1'
},
context : 'someModule'
});
someModule(['jquery'],function(_$){
var _requiredJQ;
console.log('>> require maybe 1.9.1');
console.log('>> require',window.$().jquery); // 1.10.2 or 1.9.1
console.log('>> require',_$().jquery); // 1.10.2 or 1.9.1
if(typeof __winJQ === 'function' && __winJQ().jquery !== '1.9.1'){
window.jQuery = window.$ = __winJQ;
}else if(window.jQuery !== _$){
_requiredJQ = window.jQuery;
window.jQuery = window.$ = _$;
_$ = _requiredJQ;
}
console.log('>> require after process _$',_$().jquery); // 1.9.1
// 非同期的な読み込みも想定して、ちょっと遅らせてチェック
setTimeout(function(){
console.log('>> require after process window.$',window.$().jquery); // 1.10.2
console.log('>> require after process _$',_$().jquery); // 1.9.1
},200);
});
})();
</script>
ここではまず最初に既存のjQueryの存在をチェックしています。次に、RequireJSの読み込み後に別のバージョンがロードされてるかもしれないのを想定してダミーのconfigを用意しています。
モジュールが読み込まれた後には、最初にグローバルをチェックしたjQueryのバージョンが1.9.1以外であれば、require.jsに登録されたのは1.9.1と考えられるので、保持している参照をグローバルにexposeします。また、1.10.2が登録されていれば、モジュールに渡ってくるjQueryは1.10.2で、グローバルのほうには1.9.1が出力されていると考えられるので、入れ替えます。
正直ここまでやる必要があるかないかと言われれば、無いに1票です。(とりあえず万々歳)
まとめ
ほんとはjQueryのソースのexpose部分にスラッシュを2本入れてしまいたい衝動にかられるような内容ですが、今回のようなパターンは、初期設計がしっかりしていれば、起こらないパターンかと思います。
しかし既存のページに何かモジュールを追加しなくてはならない時や、設計を制御できない場合は起こることもあるかもしれませんので、挙動を把握しておくのが必要かと思いますた。
誰かもっとスマートなやり方あったら教えてください。
おわり。。。
と思ったら
まさにこのメモを書き終わったタイミングでjQueryのGitHubを見たら、こんなコミットが。。。
およよ。