uhyohyo.net

JavaScript初級者から中級者になろう

十六章第五回 Promise

今回はES6で追加されたPromiseを解説します。Promiseは、簡単にいうと非同期処理を抽象化したものです。

従来の非同期処理

非同期処理というのはおおざっぱに言えばすぐには結果が得られない処理です。今までにもいくつかの例を紹介しました。例えば、XMLHttpRequest十三章第一回)です。これは、次のように使いました。

var xhr=new XMLHttpRequest();
xhr.open("GET","/index.html");
xhr.send(null);
xhr.addEventListener("load",function(e){
  console.log(xhr.status, xhr.responseText);
});

これは非常に単純なコードで、/index.htmlをGETで読み込んでその結果を表示するというものです。もちろん実際はエラー処理なども行う必要があるのでさらに複雑なコードを書く必要がありました。ここでは必要最低限の記述としました。

このような通信というのは、すぐには結果が得られない処理の代表例です。インターネット上でデータをやりとりするには当然時間がかかります。さらに、ファイルの読み書きなども一般には非同期の処理になります。自分のパソコンの中のことなのにと思うかもしれませんが、実際に処理を行うCPUの動作速度に比べてハードディスクなどの動作速度は遅いので、CPU側からしたら待たされることに変わりはありません。

さて、このコードでは、XHRの結果を得るためにイベントを登録しています。イベントというのは、何かが起こったときに予め登録しておいた関数(イベントハンドラ)を呼んでもらうという方式ですね。今回の場合はloadイベントで、これは通信が正常に完了して結果を得たことを表すものです。

ちなみに、HTTPリクエストの発行はsendメソッドで行われますが、通信は非同期なのでプログラムはそのまま進み、addEventListenerが実行されます。これにより、通信が完了した時点ではちゃんとイベントが登録されていることが保証されます。

非同期処理の方法としていままで紹介したものの多くはこのようにイベントを使うものです。これは、このサイトで多く紹介してきたDOMがもともとイベントをベースにした設計になっており、非同期処理もその延長上のものとして定められてきたことによります。

非同期処理の他の方式としては、コールバックを渡すというものがあります。この方式は恐らく今まで紹介したことがないですが、ある関数の引数として関数を渡して、処理が終わったら呼んでもらうというものです。

単純な例としてはsetTimeoutがあります。これは一定時間後に関数を呼んでもらうというものです。

setTimeout(function(){
  console.log("foo");
},5000);

このコードは5000ミリ秒後、すなわち5秒後に関数を呼んでもらうもので、実際に実行してみると5秒後にコンソールにログが表示されます。

これは「5秒待つ」という処理を非同期に行なったと考えることもできます。

より有用な例としては、すぐには試すことができないかもしれませんが、node.jsのfsモジュールによるファイル読み込みの例を挙げてみます。

var fs=require('fs');

fs.readFile("ファイル名",{encoding:"utf8"},function(err,data){
  console.log(data);
});

このコードでは、fs.readFileメソッドを呼び出すとファイル読み込みを行い、読み込みが完了したら第3引数に指定したコールバック関数が呼ばれます。引数dataにファイルの中身が文字列で入っています。

特徴は、コールバックに引数が2つあることです。第1引数のerrはその名の通りエラーを表します。非同期処理の途中でエラーが発生した場合、この引数errにErrorオブジェクトが入っています。エラーがないときはnullになります。エラーの例は読み込もうとしているファイルが無かった、あるいはファイルを読み込む権限がなかったなどです。コールバック関数では、errに何か入っているかどうか調べることでエラーに対応することができます。

ここまで非同期処理の例を紹介して何が言いたかったかというと、非同期処理には複数のパターンがあるということです。もっとも、どの例においても関数を呼び出してもらうということは共通していますが。

ECMAScript6では、Promiseを導入することで非同期処理を抽象化しています。これにより非同期処理に関する処理を書きやすくなります。

Promiseの利用

ではいよいよ、Promiseをどう使うのか紹介します。Promiseを使った非同期処理を行う関数は、コールバックを受け取ったりする代わりにPromiseオブジェクトを返します。例えば先ほどのファイル読み込みを例にとると、従来の方法では

readFile("ファイル名",function(err,data){
  //コールバック関数
});

としていたのを、次のようにすることができます。

p = readFile("ファイル名");

この返り値pがPromiseオブジェクトです。Promiseを使った非同期処理では、このPromiseに対して、処理終了時の動作を登録します。

それには、thenメソッドを使います。

p.then(function(result){
  /* ここで処理終了時の動作 */
});

関数を渡すのは今までと同じですね。

ここでこのコールバック関数に渡される引数は、Promiseの処理の結果です。このように、Promiseが表す非同期処理には結果を伴うことができます。そうでないと、ファイル読み込みなどを表現できませんね。

実は、Promiseが表す非同期処理が終了する場合には2種類あります。成功(fulfilled)と失敗(rejected)です。いま登録したのは成功時の処理です。失敗時の処理も登録するには、thenメソッドに引数を2つ渡します。2つ目の関数が失敗時の処理になります。

p.then(function(result){
  /* ここで処理終了時の動作 */
},function(result){
  /* ここでエラー時の動作 */
});

失敗時も結果があります。一般にこれはエラーを表すオブジェクトになります。

失敗時の処理のみを登録するには、thenの代わりにcatchメソッドを呼び出します。Promiseのインスタンスが持つメソッドはthenとcatchの2つです。

長々と書きましたが、要するにPromiseオブジェクトを得たら、thenメソッドで成功時の処理と失敗時の処理を登録すればOKということです。簡単ですね。

Promiseを作る

Promiseの使い方は分かりました。次の話題は、どうやってPromiseを作るかということです。非同期処理を実装したいときは自分でPromiseオブジェクトを作って返してあげる必要があります。

これもそんなに難しくありません。newでPromiseのインスタンスを作ります。そのとき、引数として関数を渡します。

var p = new Promise(function(fulfill,reject){
});

この関数は即座に呼び出されます。渡される2つの引数がポイントです。ここではfulfillとrejectと名づけたこの引数はそれぞれまた関数で、fulfillを呼び出すとこのPromiseは成功になり、rejectを呼び出すと失敗になります。これらに引数を渡すことで、それがPromiseの結果となります。例えば、先ほど紹介したsetTimeoutを使って、5秒後に成功するPromiseを作るには次のようにします。

var p = new Promise(function(fulfill,reject){
  setTimeout(fulfill,5000);
});

作り方は以上です。より具体的な例として、XHRをPromiseでラップして利用するサンプルを用意しました。

内部でXHRを使ってHTTP通信を行うPromiseを返すxhrPromise関数を用意して、それを使うサンプルです。ページを開いて少ししたらXHRで取得したトップページのソースが表示されます。

内部はXHRの方式に従って処理を行なっていますが、それを隠蔽してPromiseを返しています。使う側は、内部がどうなっているのかをあまり気にせず、HTTP通信を行うPromiseであるということがわかっていればOKです。

Promiseのメソッド

さて、Promiseの作り方も分かったので、ここからは少し細かい話題です。よく使うPromiseを作るためのメソッドがあらかじめ用意されています。

例えば、非同期処理なので、複数の処理を同時に行うことができます。すると「全ての処理が終わるまで待つ」ということをやりたくなります。これを実現するのがPromise.allメソッドです。引数としてPromiseの配列を渡すと、それらが全て成功したら成功となるような新しいPromiseを作って返してくれます。そのときの結果は、各Promiseの結果を配列でまとめたものになります。

引数で渡したPromiseが一つでも失敗すると、その時点で返されたPromiseも失敗となります。

Promise.allは、渡されたPromiseがどんな処理をするかは一切気にしません。Promiseが成功するか失敗するか、またそれがいつかということにのみ興味を持ちます。(というより、それ以外の情報をPromiseは提供してくれません。)

そこで、先ほどのサンプルで作ったPromiseをPromise.allに渡してみるのがサンプル2です。ちゃんと動作していることが分かりますね。

ちなみに、Promise.allに渡すのは配列だけでなくiterable(一六章第二回)でも構いません。

もうひとつ、Promise.raceというメソッドもあります。こちらは渡された全てのPromiseが解決するまで待つのではなく、どれか1つが解決した時点で終了するPromiseを返します。なお、解決(resolve)というのはPromiseが成功または失敗することを指します。

最も早く解決したPromiseが成功した場合、その結果を伴って新しいPromiseも成功します。同様に、最も早く解決したPromiseが失敗した場合は新しいPromiseも失敗で終わります。他のPromiseの結果は無視されます(結果が無視されるだけで、非同期処理自体が停止するようなことはありません)。raceという名前は一番早く解決したのを採用するという動作を表しています。

こちらも使ってみました。サンプル3です。これはなかなか面白い動作をします。3つの通信のどれが一番早く完了するかというのは運によるところもあるので、開くたびにどれが表示されるか変わるということが起こります。

次に、Promise.resolveメソッドを紹介します。これは、常に成功するPromiseを作って返します。そのとき結果の値は、Promise.resolveメソッドに渡した値になります。つまり、例えば

Promise.resolve(4)

というのは、次と同様です。

new Promise(function(fulfill,resolve){ fulfill(4); })

また、Promise.rejectメソッドもあり、こちらは常に失敗するPromiseを作って返すものです。結果の値はPromise.rejectメソッドに渡した値です。

これら2つのメソッドは大したことをしているわけではありませんが、このようなPromiseを作る必要があるときに簡単に、また分かりやすく書くことができるので有用です。

作った時点で既に結果がわかっているようなPromiseを作る意味も、無いわけではありません。例えばPromiseを返すメソッドを作るときに、引数がおかしいのでエラーを出したい場合などは、必ず失敗するPromiseを返すことができます。

ちなみに、Promise.resolveの引数としてPromiseを渡した場合は、そのPromiseをそのまま返します。これは、Promise.resolveが「値をPromiseに変換する」という役割を持っていると見なせば理解できます。

Promiseチェーン

Promiseに関する最後の話題は、Promiseチェーンです。

実は、Promiseのメソッドthenやcatchは、新しいPromiseを返します。次の場合を考えます。

var p1 = makeSomePromise();
p1.then(function(result){
  console.log(result);
});

ここでmakeSomePromise関数は、何らかのPromiseを作って返す関数だと思ってください。先ほどのサンプルのxhrPromise関数とかでもいいです。これは先ほどまでと同様にthenで成功時の処理を記述しています。いままではthenの返り値などというものに注目していませんでしたが、実はここでPromiseが返されているのです。すなわち、

var p1 = makeSomePromise();
var p2 = p1.then(function(result){
  console.log(result);
});

として、p2がPromiseになっています。このPromiseはどんな振る舞いをするのでしょうか。

実はp2は、p1.thenに渡された関数の返り値を結果として成功するようなPromiseになっています。実際に実行可能な以下のサンプルで確かめてみます。

var p1 = Promise.resolve(4);
var p2 = p1.then(function(result){
  console.log("p1: ",result);
  return 5;
});
p2.then(function(result){
  console.log("p2: ",result);
});

p1.thenによって返されたPromiseであるp2に対してさらにthenメソッドを呼び出し、p2がどう解決されるのか確かめました。次の結果になります。

p1: 4
p2: 5

ここで、p1.thenに渡すメソッドを次のように変えてみましょう。

var p1 = Promise.resolve(4);
var p2 = p1.then(function(result){
  console.log("p1: ",result);
  return result*2;
});
p2.then(function(result){
  console.log("p2: ",result);
});

p1.thenのメソッドの返り値、すなわちp2の結果に、p1の結果を利用しています。これの結果は以下です。

p1: 4
p2: 8

いま、p1の結果に注目せずp2の結果のみ注目しましょう。

var p1 = Promise.resolve(4);
var p2 = p1.then(function(result){
  return result*2;
});
p2.then(function(result){
  console.log("p2: ",result);
});

さらに変数p2を介さないように変更すると以下のようになります。

var p1 = Promise.resolve(4);
p1.then(function(result){
  return result*2;
})
.then(function(result){
  console.log("p2: ",result);
});

thenメソッドの返り値に対してそのままthenメソッドを呼ぶ形になりました。このようにメソッドを続けて呼ぶ形は一般にメソッドチェーンと呼ばれます。

こうなると、1つめのthenは結果を利用するというより、Promiseの結果を変換するという役割を果たしていることが分かります。このようにthenをつなげて値を変換するのが最も簡単なPromiseチェーンの形です。

さらに、ここでの返り値として普通の値ではなくPromiseを返すことができ、それにより非同期処理により値の変換を行うことができます。例えば、5秒かけて値を倍にするPromiseを返すdoublePromise関数を用意します。これで作ったPromiseを返り値にしてみましょう。

function doublePromise(value){
  return new Promise(function(fulfill,resolve){
    setTimeout(function(){
      fulfill(value*2);
    },5000);
  });
}

var p1 = Promise.resolve(4);
p1.then(function(result){
  return doublePromise(result);
})
.then(function(result){
  console.log("p2: ",result);
});

実行すると、5秒たってp2: 8と表示されます。

p1自体は一瞬で解決されるはずですが、thenの処理時のPromiseが5秒かかるので、全体としても5秒かかっています。この仕組みにより、逐次的に非同期処理を実行することができますね。なお、上のサンプルの後半はよく見ると次のように書けることが分かります。

var p1 = Promise.resolve(4);
p1.then(doublePromise)
.then(function(result){
  console.log("p2: ",result);
});

こう書くと多少わかりにくくなるかもしれませんが、間に処理を挟んでいるという感じがとてもしますね。

ちなみに、返り値としてPromiseを返すといいましたが、Promiseでなくてもthenメソッドさえあれば同様の動作をすることになっています。ただし、そのthenメソッドはPromiseのそれと互換のある動作をすることが期待されています。めちゃくちゃなthenをもつメソッドを渡せばめちゃくちゃな動作をさせることは一応可能です。ちなみに、Promiseの文脈では、thenメソッドを持つオブジェクトはthenableと呼ばれることがあります。

エラーの処理

さて、Promiseチェーンでは、何事もなければthenを順に巡っていくような動作をすることがわかりましたが、Promiseには成功だけでなく失敗もあります。失敗が発生したときにPromiseチェーンがどんな挙動をするのか見ておきましょう。

実は、Promiseの成功または失敗中に例外が発生した場合、Promiseは失敗します。例外を発生させる一番簡単な方法はthrowすることですので、throwしてみましょう。次のサンプルを見てください。

var p1 = Promise.resolve(4);
var p2 = p1.then(function(result){
  throw "foo";
});
p2.then(function(result){
  console.log("p2: ",result);
},function(err){
  console.log("p2 fails: ",err);
});

これを実行するとp2 fails: fooという結果になります。このように、p1.thenの実行中にエラーが発生した場合、p1.thenの返り値(今回はp2)が失敗となります。p1.thenの実行がちゃんと返り値を返して終了すればPromiseは成功ですから、それに対応する振る舞いとしてはとても妥当ですね。

なお、返り値にPromiseを返すというパターンの場合、そのPromiseが失敗ならp2も失敗となります。次の例です。

var p1 = Promise.resolve(4);
var p2 = p1.then(function(result){
  return Promise.reject("foo");
});
p2.then(function(result){
  console.log("p2: ",result);
},function(err){
  console.log("p2 fails: ",err);
});

次に、以下の場合を見てみましょう。

var p1 = Promise.reject("err");
var p2 = p1.then(function(result){
  return result*2;
});
p2.then(function(result){
  console.log("p2: ",result);
},function(err){
  console.log("p2 fails: ",err);
});

p1が失敗する場合です。この場合p2はどう解決されるでしょうか。p2は、thenの第2引数は指定されていないので、p1が成功したときの振る舞いは設定されていますが、p1が失敗したときの振る舞いは指定されていません。この場合はデフォルトの動作をされます。失敗時のデフォルトの動作は以下です。

function(err){ throw err; }

よって、p2はp1が失敗したことを受けてデフォルト動作によりthrowするので、p2も失敗となります。

では、p2がp1の失敗を処理できた場合はどうでしょう。

var p1 = Promise.reject("err");
var p2 = p1.catch(function(err){
  //errを無視して値を返す
  return 10;
});
p2.then(function(result){
  console.log("p2: ",result);
},function(err){
  console.log("p2 fails: ",err);
});

今回はcatchで登録したので、p1の失敗を受けてp2は登録した処理を実行し、10を返します。するとp2は結果10で成功ということになります。従って、これの結果はp2: 10ということになります。ここで注目すべきは、Promiseチェーンの中で失敗が発生しても、catchすれば成功に戻して続けることができるという点です。これは、thenの第二引数でも大丈夫です。thenを使うことで、成功と失敗を両方扱うことが可能です。

さて、このままでp1を成功に変えたらどうなるでしょうか。

var p1 = Promise.resolve(5);
var p2 = p1.catch(function(err){
  //errを無視して値を返す
  return 10;
});
p2.then(function(result){
  console.log("p2: ",result);
},function(err){
  console.log("p2 fails: ",err);
});

この場合、p1は成功となります。p2はp1が失敗した場合の処理は登録されていますが成功した場合の処理は登録されていませんので、やはりデフォルト動作が適用されます。成功時のデフォルト動作は以下です。

function(value){ return value; }

つまり、そのまま値を素通しして成功となります。よってこの場合はp2は結果5で成功することになります。

先ほど見た失敗時のデフォルト処理も、結局失敗を素通しするということになるので、処理が登録されていない場合は成功でも失敗でも素通りするということになります。

さて、今まで紹介したPromiseチェーンですが、よく見るのは次のような形でしょう。

var p = makeSomePromise();
p.then(function(){
  /* 成功時の処理 */
}).catch(function(){
  /* 失敗時の処理 */
});

thenには2つの引数を渡すことで成功時と失敗時の処理を両方書くことができますが、このようにthenとcatchに分けて書くこともできます。

pが成功した場合thenで処理されて、catchは素通りします。pが失敗した場合はthenを素通りしてcatchで処理されます。これは基本的には、

var p = makeSomePromise();
p.then(function(){
  /* 成功時の処理 */
},function(){
  /* 失敗時の処理 */
});

と同じですが、1つ違いがあります。thenの処理の中で例外が発生した場合です。上の場合は、thenが返したPromiseが失敗したという扱いになるので、次のcatchでエラーが処理されます。一方下の場合は、thenでエラーが発生するとそのPromiseは失敗となりますが、その次のPromiseはないので失敗時の処理は呼ばれません。下のやつに書いてある失敗時の処理は、あくまでpが失敗したときの処理となっています。

このようにthenやcatchがメソッドチェーンで繋がっているのを見たら、実はこういう仕組みになっているということを思い浮かべてみましょう。


まとめとしては、Promiseの作り方と使い方を紹介しました。非同期処理をする関数やライブラリを作るときは、Promiseを返してみるのが今風ということです。