jQuery.Deferredでスクリプトの動的読み込みを試してみる
存在は知っていたけれど、いままで使ったことがなかった$.Deferredを今回試す機会がありました。以前トップページからのリンク先をいくつかPJax化(History.pushState/popStateを使った非同期ページ遷移)したんですが、遷移先のページで別途スクリプトファイルが必要なページが出てきて、動的なスクリプトの読み込みが必要になり、そこで$.Deferredの出番となりました。
$.DeferredはAjaxに限らず、Window.setTimeoutなど非同期に動くメソッドを使用するときに、コールバック関数の実行順序を保つために使われます。実のところ、コールバックを順序立てて実行するのに$.Deferredは必須ではありません。たとえば、$.ajaxメソッドには、非同期通信が成功したら呼ばれるコールバックを指定できるsuccessプロパティがあり、そのコールバック関数で次に開始するAjax処理コードを書いていくようにすれば、非同期通信の順番を保つことができます。
しかし、この方法だとよく言われるようにコールバックが入れ子になってしまうデメリットがあります。実際にサンプルを書いてみます。
あらかじめ、動的に読み込む必要のあるスクリプトを下記のように配列にまとめておきます。配列の第1要素のファイルは、それ以降のファイルのコアファイルで最初に読み込まないとならないものです。JavaScriptの配列は順序が保証されているので、この配列の順序で読み込みが実行できればうまくいくはずです。
var scripts = [ '/lib/shCore.js',//最優先でロードするコアファイル '/lib/shBrushXml.js', '/lib/shBrushCss.js', '/lib/shBrushJScript.js', '/lib/shBrushPhp.js' ];
ちなみにshCore.js以下のファイルは有名なシンタックスハイライトのSyntaxHighlighter.jsです。ページ内に掲載するソースコードを見やすく表示する機能があります。コード書きの人には必須のアイテムですね。
そしてこれが驚愕のコールバック地獄と化したパターン。でも、これはあくまでサンプルなので、実用コードはもっとグチャグチャであることが容易に予想されます。$.Deferredを利用するのは、このようにインデントが増えてソースコードの見通しが悪くなる(保守しにくくなる)のを防ぐメリットがあるのです。
$.getScript(scripts[0], function () { $.getScript(scripts[1], function () { $.getScript(scripts[2], function () { $.getScript(scripts[3], function () { $.getScript(scripts[4], function () { //読み込みが正しく終了しないと、ここでエラーが発生する。 SyntaxHighlighter.all(); }); }); }); }); });
それでは、上記のソースコードを$.Deferredを使用したパターンで書いてみます。
$.getScript(scripts[0]). done(_.bind($.getScript, null, scripts[1])). done(_.bind($.getScript, null, scripts[2])). done(_.bind($.getScript, null, scripts[3])). done(_.bind($.getScript, null, scripts[4])). done(function () { //読み込みが正しく終了しないと、ここでエラーが発生する。 SyntaxHighlighter.all(); });
これのいったいどこに$.Deferredが使われているんだ、と思われるかもしれません。実はjQueryの$.ajax系メソッドはバージョン1.5から、返り値のjqXHRオブジェクトに$.DeferredのPromiseインターフェイスが実装されています。すなわち$.getScript()の返り値はjqXHRオブジェクトでもあり、かつPromiseオブジェクトでもあるのです。Promiseオブジェクトは具体的にはnew $.Deferred().promise()のことです。このオブジェクトは.done()や.fail()、.then()などのメソッド群にチェーンさせることができます。なお、$.Deferredは非同期処理の成功・失敗を手動でDeferred.resolve()なりDeferred.reject()なりする必要がありますが、$.ajax系メソッドはそれらを自動的に判定をしてくれます。上のケースのようにAjax通信が成功したら.done()に次に実行する手続き(関数)を指定するだけでいいのです。
jqXHRオブジェクトについて | js STUDIO
ここで上記のサンプルコードの補足をしておきます。_.bindというメソッドは、関数型プログラミング支援ライブラリUnderscore.jsのメソッドで、関数内のthisキーワードに特定のオブジェクトを紐付けるのが本来の役割です。ただし、このサンプルでは引数の先渡し(カリー化)のために使用しています。_.bind($.getScript, null, url)の部分は、function () {$.getScript(url);}と等価です。無名関数内にAjax処理を記述する手間を省くために_.bind()メソッドを使いました。
jQueryが提供するメソッドに複数のファイルを読み込むのに適した$.when()メソッドがあります。これは並列的に複数の非同期処理を実行し、すべての処理が成功なり失敗したタイミングでコールバックを呼び出すことができます。もしも読み込むファイルに依存関係がないのなら、こちらのメソッドを使う方がいいかもしれません。ただし、上記のように、依存関係のあるケースだとうまくいきません。
//依存関係があるファイル群を$.when()で処理すると失敗することがある。 $.when( $.getScript(scripts[0]), $.getScript(scripts[1]), $.getScript(scripts[2]), $.getScript(scripts[3]), $.getScript(scripts[4]) ).then(function () { //読み込みが正しく終了しないと、ここでエラーが発生する。 SyntaxHighlighter.all(); });
これをChromeで実行するとエラーが発生することがあります。原因が通信エラーやタイムアウトでないにもかかわらず、100%成功しないのです。失敗したときにjQueryがいくつかのファイルについて、parsererrorを返してくることから考えておそらく、最初のコアファイルのロード終了より早く依存ファイルの読み込みが終わり、それが先にパースされてエラーを起こしたのではと思います。$.when()メソッドはあくまで非同期処理を並列化するもので、処理の順番を保証するものではないのです。$.when()を使用する場面があるとすれば、読み込みのタイミングが順不同でよいファイルの非同期処理をするときだと思います。
読み込むファイルが少なければ、上のような書き方でもいいのですが、この方法だとスクリプトの数が増減したときに手動で書き換えをしないといけなくなります。もっともよい解決策はrequireJSといったファイル管理ツールを使うことだと思いますが。とはいえ、自分のような数十ページ程度の小規模サイトの管理にこういうのを使うのは大げさと思うことがありまして、自分のサイトではまだこれらのツールを使っていません。
さて、読み込むファイルを配列にしたのですから、for文などのループ処理でこれらを処理したいものです。ループ処理にすることで、スクリプト数の変更に対応でき、コードもさらに短くなります。
var chain; //この変数にPromiseオブジェクトを代入してチェーンさせる。 for (var i = 0, l = scripts.length; i < l; i += 1) { //変数chainにはPromiseオブジェクトを入れるので、初回だけこのように条件分岐する。 if (_.isUndefined(chain)) { chain = $.ajax(scripts[i]); continue; } //2回目以降のループではchainを.then()につなぐ chain = chain.then(_.bind($.ajax, null, scripts[i])); } //すべての処理が終了したら実行するコールバックを指定。 chain.then(function () { SyntaxHighlighter.all(); });
$.Deferredの話をしておいて、ソースコードに$.Deferredがまったく出てこないのもどうなのかと思い、$.Deferredクラスを使用したコードに書き換えてみます。
var chain; for (var i = 0, l = scripts.length; i < l; i += 1) { if (_.isUndefined(chain)) { chain = deferAjax(scripts[i]); continue; } chain = chain.then(_.bind(deferAjax, null, scripts[i])); } chain.then(function () { SyntaxHighlighter.all(); }); //Ajax処理にDeferredを取り入れたもの。手動でdeferred.resolve()している。 function deferAjax(url) { var deferred; deferred = $.Deferred(); $.ajax(url).done(function () { deferred.resolve(); }); return deferred.promise(); }