"promise" による JavaScript での非同期プログラミング
本記事は、マイクロソフト本社の IE チームのブログ から記事を抜粋し、翻訳したものです。
【元記事】Asynchronous Programming in JavaScript with Promise (2011/9/12 5:04 AM )
非同期パターンは、Web プログラミングの機能を高めることから幅広く使用されるようになり、その重要性はますます高まっています。しかしながら、非同期パターンを JavaScript で利用する場合には、複雑になることがあります。
非同期 (async) パターンをより簡単に利用できるように、JavaScript ライブラリ (jQuery や Dojo など) に、"promise" (または deferred) と呼ばれる抽象化が追加されました。
これらのライブラリを使用すると、ECMAScript 5 を高度にサポートしていれば、開発者はどのようなブラウザーでも "promise" を使用できります。
この記事では、XMLHttpRequest2 (XHR2) を例に、Web アプリケーションで "promise" を使用する方法について説明します。
非同期プログラミングの利点と課題
まず例として、XMLHttpRequest2 (XHR2) や Web Workers のような非同期操作を開始する Web ページについて考えてみましょう。
作業の一部を "同時進行" で行うことには利点があります。その一方、非同期操作によって Web ページが実行している内容を調整しながら、ユーザーへの応答性を確保し、操作を妨げることがないページを開発することは複雑になります。これは、プログラムを単純かつ直線的に実行することができないためです。
非同期呼び出しを実行する場合、正常に完了した操作と、実行中に生じる可能性のあるエラーの両方に対処する必要があります。
非同期呼び出しが正常に完了したら、その結果をさらに別の AJAX 要求に渡すことが必要な場合があります。このような "ネストされたコールバック" が、複雑さを生む原因となり得ます。
function searchTwitter(term, onload, onerror) {
var xhr, results, url;
url = 'http://search.twitter.com/search.json?rpp=100&q=' + term;
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
onload(results);
}
};
xhr.onerror = function (e) {
onerror(e);
};
xhr.send();
}
function handleError(error) {
/* エラーを処理 */
}
function concatResults() {
/* ツイートを日付順に並べる */
}
function loadTweets() {
var container = document.getElementById('container');
searchTwitter('#IE10', function (data1) {
searchTwitter('#IE9', function (data2) {
/* 日付のため再度シャッフル */
var totalResults = concatResults(data1.results, data2.results);
totalResults.forEach(function (tweet) {
var el = document.createElement('li');
el.innerText = tweet.text;
container.appendChild(el);
});
}, handleError);
}, handleError);
}
このように、ネストされたコールバックによって、アプリケーションに固有のビジネス ロジックはどのコードなのか、また非同期呼び出しを処理するために必要なスケルトン コードは何なのか、といったことが理解しづらくなっています。またエラー処理が断片化してしまっており、エラーが発生しているかどうかは数か所で確認することが必要になります。
非同期処理の調整が複雑になることを避けるために、開発者は、わかりやすくて一貫性のある、ネストされたコールバックに代わるエラー処理の実行方法を求め続けてきました。
promise
そのようなパターンの 1 つが promise です。promise は、長期にわたって実行される可能性があり、必ずしも操作が完了しているわけではない結果を表します。
このパターンでは、処理時間の長い計算が完了するまで操作をブロックして待機するのではなく、約束 (promise) された結果を表すオブジェクトを返します。
この例の 1 つに、ネットワーク待ち時間が不安定なサードパーティ システムへの要求作成があります。待機している間、アプリケーション全体をブロックするのではなく、アプリケーションはその値が必要になるまで自由に他の処理を行うことができます。promise には、状態の変更を通知するためのコールバックを登録するメソッドが実装され、このメソッドには通常 then という名前が付いています。
var results = searchTwitter(term).then(filterResults);
displayResults(results);
この概念のしくみを示すために、一般的なライブラリでいくつか派生形のある CommonJS Promise/A 提案を見ていきます。promise オブジェクトの then メソッドには、完了と拒否の状態に対するハンドラーが追加されています。この関数は別の promise オブジェクトを返すため、promise のパイプラインが可能になります。これにより非同期操作を連携させ、最初の操作の結果を 2 番目の操作に渡すことができます。
then(resolvedHandler, rejectedHandler);
resolvedHandler コールバック関数は、promise が完了した状態になると呼び出され、計算の結果を渡します。rejectedHandler は、promise が失敗した状態になると呼び出されます。
上の例は後でもう一度使用します。そこでは promise の仮のコード例を使用して、Twitter を検索する AJAX 要求を作成し、そのデータを画面に表示して、エラーを処理します。ここではまず、ごく基本的な要素を使用してゼロから設計した場合に、promise ライブラリがどのような挙動をするかを例として見ていきましょう。まず、promise を保持するオブジェクトに形が必要です。
var Promise = function () {
/* promise を初期化 */
};
次に、then メソッドを実装して、promise の状態の遷移に応じて操作を連携できるようにする必要があります。このメソッドでは 2 つの関数を使用して、promise の状態が完了になった場合と拒否になった場合を処理します。
Promise.prototype.then = function (onResolved, onRejected) {
/* 状態の遷移に応じてハンドラーを呼び出す */
};
また、未完了から完了、あるいは未完了から拒否へ状態を移行させるためのメソッドもいくつか必要です。
Promise.prototype.resolve = function (value) {
/* 未完了から完了へ移行 */
};
Promise.prototype.reject = function (error) {
/* 未完了から拒否へ移行 */
};
promise オブジェクトに対するスケルトン コードができたところで、#IE10 タグの付いたツイートを検索する上記の例を詳しく見ていきましょう。
まず、指定された URL に XMLHttpRequest2 を使用して AJAX GET 要求を作成するメソッドを作成し、promise でラップします。次に、特定の検索語を指定して AJAX ラッパー メソッドを呼び出す Twitter 専用のメソッドを作成します。最後に、検索関数を呼び出し、順序指定されていない一覧の形式で結果を表示します。
function searchTwitter(term) {
var url, xhr, results, promise;
url = 'http://search.twitter.com/search.json?rpp=100&q=' + term;
promise = new Promise();
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
promise.resolve(results);
}
};
xhr.onerror = function (e) {
promise.reject(e);
};
xhr.send();
return promise;
}
function loadTweets() {
var container = document.getElementById('container');
searchTwitter('#IE10').then(function (data) {
data.results.forEach(function (tweet) {
var el = document.createElement('li');
el.innerText = tweet.text;
container.appendChild(el);
});
}, handleError);
}
1 つの AJAX 要求を promise として作成できたので、今度は複数の AJAX 要求を作成し、結果を調整する場合について考えてみましょう。
このシナリオを処理するには、promise オブジェクトに when メソッドを作成して、呼び出す対象の promise をキューに格納します。promise の状態が未完了から完了、あるいは未完了から拒否に移行すると、該当するハンドラーが then メソッドに呼び出されます。when メソッドは本質的に、すべての操作が完了するのを待ってから処理を継続する、分岐 - 結合 (fork-join) 処理です。
Promise.when = function () {
/* 日付のため再度シャッフル */
};
ここで思い出していただきたいことは、これらのサンプル コードは通常の JavaScript 以外の何物でもないということです。もちろん Web 開発者は、promise のようなライブラリを自作することもできます。しかし、一般的な JavaScript ライブラリで公開されている promise パターンを活用すると、便利で一貫性も確保されます。
jQuery と Dojo ツールキットの promise について
開発者が利用可能な JavaScript ライブラリには、promise を何らかの形で実装したものが数多くあります。ここでは、promise または同様の概念が公開されているいくつかのライブラリについて見ていきましょう。
Dojo ツールキット
このパターンが最初に広く活用されたのは、Dojo ツールキット バージョン 0.9 の deferred オブジェクトでした。
先ほどの CommonJS Promises/A 提案と同様に、このオブジェクトでも then メソッドが公開されていて、操作の完了とエラーの 2 つの状態を処理し、promise を連携させることができます。
dojo.Deferred オブジェクトでは、さらに resolve と reject という 2 つのメソッドが公開されています。resolve は promise の操作を完了させ、reject は promise を拒否の状態にします。以下は dojo.Deferred オブジェクトの使用例で、URL への AJAX 要求を作成し、その結果を解析します。
function searchTwitter(term) {
var url, xhr, results, def;
url = 'http://search.twitter.com/search.json?rpp=100&q=' + term;
def = new dojo.Deferred();
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
def.resolve(results);
}
};
xhr.onerror = function (e) {
def.reject(e);
};
xhr.send();
return def;
}
dojo.ready(function () {
var container = dojo.byId('container');
searchTwitter('#IE10').then(function (data) {
data.results.forEach(function (tweet) {
dojo.create('li', {
innerHTML: tweet.text
}, container);
});
});
});
幸いなことに、dojo.xhrGet などの一部の AJAX メソッドは dojo.Deferred オブジェクトを返します。したがって、これらのメソッドを自分でラップする必要はありません。
var deferred = dojo.xhrGet({
url: "search.json",
handleAs: "json"
});
deferred.then(function (data) {
/* 結果を処理 */
}, function (error) {
/* エラーを処理 */
});
Dojo で次に導入された概念は、dojo.DeferredList です。これにより、開発者は複数の dojo.Deferred オブジェクトを同時に処理し、then 関数に渡されたコールバック ハンドラーへその結果を返すことができます。これは、先ほど独自に作成した promise オブジェクトの when メソッドに相当するものです。
dojo.require("dojo.DeferredList");
dojo.ready(function () {
var container, def1, def2, defs;
container = dojo.byId('container');
def1 = searchTwitter('#IE10');
def2 = searchTwitter('#IE9');
defs = new dojo.DeferredList([def1, def2]);
defs.then(function (data) {
// 例外を処理
if (!results[0][0] || !results[1][0]) {
dojo.create("li", {
innerHTML: 'an error occurred'
}, container);
return;
}
var totalResults = concatResults(data[0][1].results, data[1][1].results);
totalResults.forEach(function (tweet) {
dojo.create("li", {
innerHTML: tweet.text
}, container);
});
});
});
Dojo ツールキットは、おそらくこのパターンを最初に実装したライブラリですが、jQuery など、他の主要ライブラリでも同様のパターンが公開されています。
jQuery
jQuery では、バージョン 1.5 で Deferred という新しい概念が導入されました。これも、CommonJS Promises/A 提案の実装の派生形です。Deferred オブジェクトでは then メソッドが公開され、これにより操作の完了とエラーの両方の状態を処理できます。Dojo と同様、このオブジェクトでも resolve と reject が公開されています。jQuery の Deferred オブジェクトは、$.Deferred 関数を呼び出すと作成できます。
function xhrGet(url) {
var xhr, results, def;
def = $.Deferred();
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
def.resolve(results);
}
};
xhr.onerror = function (e) {
def.reject(e);
};
xhr.send();
return def;
}
function searchTwitter(term) {
var url, xhr, results, def;
url = 'http://search.twitter.com/search.json?rpp=100&q=' + term;
def = $.Deferred();
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
def.resolve(results);
}
};
xhr.onerror = function (e) {
def.reject(e);
};
xhr.send();
return def;
}
$(document).ready(function () {
var container = $('#container');
searchTwitter('#IE10').then(function (data) {
data.results.forEach(function (tweet) {
container.append('<li>' + tweet.text + '</li>');
});
});
});
Dojo と異なる点として、jQuery では、then メソッドから別の promise が返されません。その代わりに、jQuery には pipe メソッドが用意され、これによって操作を連携することができます。jQuery にはこの他にも、pipe メソッドや jQuery スタイルの $ 構文を利用したフィルタリングなど、構成を柔軟にする便利なメソッドが用意されています。
jQuery 1.5 リリースでは AJAX メソッドが変更され、promise インターフェイスを直接実装する jqXHR オブジェクトを返すようになりました。
$.ajax({
url: 'http://search.twitter.com/search.json',
dataType: 'jsonp',
data: { q: '#IE10', rpp: 100 }
}).then(function (data) {
/* データを処理 */
}, function (error) {
/* エラーを処理 */
});
一貫性を確保するために、jQuery.ajax メソッドには、success、error、complete メソッドも用意されています。
$.ajax({
url: 'http://search.twitter.com/search.json',
dataType: 'jsonp',
data: { q: '#IE10', rpp: 100 }
}).success(function (data) {
/* データを処理 */
}).error(function (error) {
/* エラーを処理 */
});
まとめ
開発者には、複雑な非同期プログラミングに対処するための方法が数多く用意されています。promise や deferred オブジェクトなどのよく知られているパターンや、これらを公開しているライブラリを使用することで、開発者は非同期要求をシームレスにつないで、柔軟な操作体系を作成することができます。
この記事では例として、XMLHttpRequests の promise オブジェクトと deferred オブジェクトの活用について説明しましたが、これらのパターンは Web Workers、setImmediate API、FileAPI を始めとするその他の非同期 API 上でも容易に活用できます。一般的な JavaScript ライブラリを活用すると、スケルトン コードを作成する必要がなくなります。
promise パターンは入門に適していますが、ソリューションはこれだけではありません。実際、非同期プログラミングに対処する方法として、さまざまなパターンが発表されています。これらは Web 開発者の苦労を減らしてくれる魅力的な開発手法です。そして、Web アプリケーションの作成にも必ず役立つはずです。これからもコーディングを存分に楽しんでください!
—Matt Podwysocki、JavaScript 専門家およびコンサルタント
—Amanda Silver、JavaScript プログラム マネージャー