JSDeferred 紹介

JSDeferred について

JSDeferred は JavaScript のコールバックによる非同期処理を直列的に書けるようにするために作られたライブラリです。

foofunc(function () {
	barfunc(function () {
		bazfunc(function () {
		});
	});
});
foofunc().next(barfunc).next(bazfunc);

簡単な使いかた

読み込み

まずは JSDeferred を使うために、HTML に script 要素を追加します。

<script type="text/javascript" src="jsdeferred.js"></script>
<script type="text/javascript" src="my.js"></script>

JSDeferred は外部ライブラリに依存しておらず、単体で動くため、jsdeferred.js を読みこめば十分です。これから先のコードは my.js に書いていくことにします。

最初の一歩

JSDeferred を読みこむと、Deferred というオブジェクトが定義されます。 便宜上 Deferred.define() を使って関数をグローバルにエクスポートします。もちろん、エクスポートせずに使うこともできます。

Deferred.define();

これより、グローバルな関数として、next() や loop(), call(), parallel(), wait() といった便利な関数が使えるようになります。 簡単な非同期処理を書いてみます。

next(function () {
	alert("Hello!");
	return wait(5);
}).
next(function () {
	alert("World!");
});

これは、まず Hello! が alert されたあと、5秒待ってから World! が alert される処理になります。

Deferred.define() で関数をエクスポートしない場合は以下のようになります。上記コードと全く同じ意味です。

Deferred.next(function () {
	alert("Hello!");
	return Deferred.wait(5);
}).
next(function () {
	alert("World!");
});

通常のコールバックと比べて

さて、このように書けることで、何が嬉しいのでしょうか。

コールバックを関数に渡しすスタイルでは、連続する非同期処理を関数の入れ子で表現することになります。例えば /foo.json, /bar.json, /baz.json を取得したい場合

// http.get は URI と コールバックをとる関数
http.get("/foo.json", function (dataOfFoo) {
	http.get("/bar.json", function (dataOfBar) {
		http.get("/baz.json", function (dataOfBaz) {
			alert([dataOfFoo, dataOfBar, dataOfBaz]);
		});
	});
});

このように、非同期処理が挟まるたびに、関数のネストが深くなっていてしまいます。また、もし取得したいデータが任意の数だったらどうなるでしょうか?

var wants = ["/foo.json", "/bar.json", "/baz.json"];
// どうやって書きますか?

面倒くさくでやっていられませんね。では、ここに Deferred を導入してみます。

// http.get は URI をとって Deferred を返す関数
var results = [];
next(function () {
	return http.get("/foo.json").next(function (data) {
		results.push(data);
	});
}).
next(function () {
	return http.get("/baz.json").next(function (data) {
		results.push(data);
	});
}).
next(function () {
	return http.get("/baz.json").next(function (data) {
		results.push(data);
	});
}).
next(function () {
	alert(results);
});

コード自体は若干長くなりましたが、処理が一直線になりました。何やら似たような処理が3回も続いているので、まとめてみます。

var wants = ["/foo.json", "/bar.json", "/baz.json"];
var results = [];
loop(wants.length, function (i) {
	return http.get(wants[i]).next(function (data) {
		results.push(data);
	});
}).
next(function () {
	alert(results);
});

コンパクトになりました。しかも、任意の数のリクエストにも対応できています。loop は、渡した関数の中で Deferred オブジェクトが返されると、 それの実行を待ってから後続の処理を継続します。

上記コードは、順番にリクエストを出していく、つまり、foo.json が読みこみ終わってから bar.json を読みこみだす、というコードですが、 実際は foo.json も bar.json も baz.json も、同時に読み込んでほしいことのほうが多いかと思われます。その場合、さらに簡潔に

parallel([
	http.get("/foo.json"),
	http.get("/bar.json"),
	http.get("/baz.json")
]).
next(function (results) {
	alert(results);
});

と書けば終りです。parallel では、複数の Deferred オブジェクトを渡すと、全てが揃ったときに次の処理が実行されます。簡単でしょ?

例外処理

さて、Deferred で地味に便利なのは、例外処理です。Firefox などのブラウザでは、非同期処理中に発生した例外は、例外コンソールさえ出ずに黙殺されます。 一体どうやってデバッグしろっていうんでしょうか?

筆者はしばしば、こういった場面に遭遇したとき、とにかく try {} catch (e) { alert(e) } で非同期処理を囲む、といったことをしますが、毎回やるのは、億劫ですしバカみたいです。

JSDeferred の場合、通常の処理の流れとは別に、例外処理の流れを作ることができます。例えば、

next(function () {
	// 何か1
}).
next(function () {
	// 非同期処理
	throw "error!";
}).
next(function () {
	// 何か2 (直前で例外が発生しているので実行されない)
});

のようなコードに例外処理を付け加えたい場合

next(function () {
	// 何か1
}).
next(function () {
	// 非同期処理
	throw "error!";
}).
next(function () {
	// 何か2 (直前で例外が発生しているので実行されない)
}).
error(function (e) {
	alert(e);
});

と、.error() を加えるだけです。これだけで、.error() をつけくわえる前の非同期処理で発生した例外を全てキャッチすることができます。

また、上記コードでは「何か2」は例外の後の処理のため実行されませんが、例外の有無に関係なく実行させたい場合、

next(function () {
	// 何か1
}).
next(function () {
	// 非同期処理
	throw "error!";
}).
error(function (e) {
	alert(e);
}).
next(function () {
	// 何か2 (error が処理されたあとのため実行される)
}).
error(function (e) {
	alert(e);
});

のように、例外処理を途中に挟みこむことができます。error() の中でさらに例外が発生しない限り、error() によって例外が処理されたと見なされて、 後続の処理が継続されるようになります。

チェイン

Deferred 手続き内で返された値が Deferred の場合、JSDeferred では、その Deferred の実行を待ちます。

next(function () {
	alert("Hello!");
	return wait(5);
}).
next(function () {
	alert("World!");
});

というコードを最初に見せましたが、wait() は「5秒待つ」という Deferred を返す関数です。Deferred の関数中でこのように Deferred オブジェクトが返された場合、 後続の処理を一旦止めて、返された Deferred の実行を待つようになります。これは、wait() に限らず、Deferred さえ帰ってくれば何でもそうです。

next() 関数も、Deferred オブジェクトを返すため、以下のようなコードを書くことができます。

next(function () {
	alert(1);
	return next(function () {
		alert(2);
	}).
	next(function () {
		alert(3);
	});
}).
next(function () {
	alert(4);
});

この場合、数字の順に処理が実行されていきます。

関数を Deferred 化する

JSDeferred を実際に使用するさい、標準に組込まれている非同期関数のほかに、既存のコールバックを渡す方法をとっている関数を Deferred を返すようにしたい場合が、 多々あるでしょう。これは実際、とても簡単です。XMLHttpRequest を例にして、何度かでてきていた http.get を実装してみます。

http = {}
http.get = function (uri) {
	var deferred = new Deferred();
	var xhr = new XMLHttpRequest();
	xhr.onreadystatechange = function () {
		if (xhr.readyState == 4) {
			if (xhr.status == 200) {
				deferred.call(xhr);
			} else {
				deferred.fail(xhr);
			}
		}
	};
	deferred.canceller = function () { xhr.abort() };
	return deferred;
}

new Deferred() で新しい Deferred オブジェクトを作成し、非同期なコールバック内でインスタンスの call() メソッドを呼ぶようにします。 これにより、Deferred に関連付けられた処理が実行されます。

同様に、fail() メソッドを呼ぶことによって、エラーを発生させることができます。.error() によってエラーをキャッチしたい場合には、 適切に fail() を呼んであげる必要があります。

canceller というものも定義していますが、これは Deferred インスタンスの cancel() を呼んだときに実行されるものです。 通常はあまり設定する機会はないでしょうが、存在ぐらいは心にとめておいてください。

自分で非同期関数を書く場合

設計上の指針の話になってしまいますが、Deferred に依存したコードを書く場合、非同期処理を行う関数はのきなみ Deferred を返すようにしておくと、 あとあと便利になります。例えすぐにその非同期処理の後続処理を書く必要がなくても、簡単に Deferred のチェインの中に組込めるようになります。

もし汎用的なライブラリを書きたいのであれば、とりあえずコールバックをとる関数にしておいて、 あとから JSDeferred と繋ぐような関数を作ってもいいかもしれません。

重い処理を分割する

JavaScript における「高速化」

JavaScript における「高速化」では、単純に処理速度の高速化というよりは、ユーザ体験のストレスをいかになくすかがとても重要です。

処理速度がいくら早くても、UIスレッドが長時間ブロックするような処理はユーザに対して大きなストレスを与えます。 JavaScript においては、総合的な速度の早さよりも、UIスレッドの最短ブロック時間のほうが重要なのです。

JSDeferred を用いると、loop() などによって処理の分割をしやすくなり、簡単に、重いループを分割して実行させたりすることができるようになります。 トータルの実行時間的には十分早いはずなのに、ブラウザのスクロール (UI) が固まったりしたとき、すぐにこういった対処をできることは、 ウェブアプリケーション開発においてとても有意なことだと考えています。

JavaScript で DOM を連続して大量に操作する必要がある場合、ブラウザによらず大抵は処理速度が悪化し、 それを処理している間はブラウザ側のスクロールなどのUI処理が止まります。 これはユーザにしてみれば非常にストレスフルなので、できるだけそういったことがないようにしたいものです。

そもそも処理自体を高速化するのは当然として、DOM 操作などはどうしようもなかったりもします。 ので、こういった場合、処理を分割して非同期に実行し、適切にブラウザのUIに処理が戻るようにします。

JSDeferred は組み込みで loop() という関数があり、これによって、ループ毎にブラウザに制御を返すことができるようになっています。

loop(1000, function (n) {
	// heavy process
});

これを JSDeferred なしでやろうとすると…… 面倒なのでコードは書きません。

長いループを自動で分割する

loop() 関数は、ループ毎に処理が分割されるため、1ループがそれなりに重い場合には効果的ですが、1ループ自体は軽いのに、ループ回数が膨大な場合、非効率的になってしまいます。 そこで、aloop() という関数を実装してみます。

function aloop (n, f) {
    var i = 0, end = {}, ret = null;
    return Deferred.next(function () {
        var t = (new Date()).getTime();
        divide: {
            do {
                if (i >= n) break divide;
                ret = f(i++);
            } while ((new Date()).getTime() - t < 20);
            return Deferred.call(arguments.callee);
        }
    });
}

この関数は、ループを繰替えしていき、20msec 以上実行した時点で処理を分割します。loop() と違い、 Deferred オブジェクトをループ関数内で返してもそれの実行を待ったりはしません (できません)。

使用例

実装について

とりあえずは README を見てみてください。