uhyohyo.net

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

十六章第五回 Promise

このページの最終更新日:

今回はES2015で追加されたPromiseを解説します。Promiseは、簡単にいうと非同期処理を抽象化したオブジェクトです。

従来の非同期処理

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


const 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で読み込んでその結果を表示するというものです。もちろん実際はエラー処理なども行う必要があるのでさらに複雑なコードを書く必要がありますが、ここでは必要最低限の記述としました。

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

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

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

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

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

単純な例としては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に何か入っているかどうか調べることでエラーに対応することができます。

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

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

Promiseの利用

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


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

としていたのを、次のようにすることができます。(注:これはあくまで説明用の例です。node.jsでこう書けるわけではありません。)


const p = readFile("ファイル名");
p.then(function(data){
  // ...
});

この例では、readFileは非同期的にファイルを読み込む関数とします。そのような関数はPromiseを返します。今回の場合pがPromiseオブジェクトになります。

Promiseオブジェクトを得たら、やはりコールバックを登録する必要があります。そこで、Promiseはそのためのthenメソッドを持ちます。thenメソッドに対してコールバック関数を渡すと、非同期処理が完了したらその関数が呼ばれます。

ところで、Promiseが表す非同期処理には結果が伴うことがあります。そうでないと、ファイル読み込みなどを表現できませんね。Promiseの結果はコールバック関数の引数として受け取ることができます。上の例では、コールバック関数が呼ばれたとき、その結果が引数dataに与えられることになります。

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


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

上の例から分かるように、失敗時も結果があり、コールバック関数に渡されます。一般に失敗時の結果はエラーを表すオブジェクトになります。

上のファイル読み込みの例の場合は、指定したファイルが存在しなかったなどの場合にPromiseの結果は失敗となり、2つ目のコールバックが呼び出されることになります。

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


p.catch(function(err){
  /* ここでエラー時の動作 */
});

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

Promiseを作る

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

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


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

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


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

この例ではPromiseに渡した関数の中で非同期処理(setTimeout)を行います。この処理は、5秒後にfulfillを呼び出します。fulfillが呼び出されたのでこのPromiseは成功となります。

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

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

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

Promiseのメソッド

さて、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チェーンです。

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


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

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


const p1 = makeSomePromise();
const 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);
});

さらに、変数p1やp2は実は要らないですね。次のようにできます。


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

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

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

さらに、上の例ではthenのコールバックは普通の値を返しましたが、ここでPromiseを返すことができます。そうすると、返されたPromiseが解決した時点で外側のPromiseも解決します。つまり、値の変換を非同期的に行うことができるのです。例えば、5秒かけて値を倍にするPromiseを返すdoublePromise関数を用意します。これで作ったPromiseを返り値にしてみましょう。


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

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

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

p1自体は一瞬で解決されるはずですが、1つ目のthenの処理(のPromise)が5秒かかるので、その次のthenに進むのも5秒後となっています。このように、Promiseチェーンでは複数のPromiseをつなげて逐次的に非同期処理を実行することもできるのです。なお、上のサンプルの後半はよく見ると次のように書けることが分かります。


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

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

エラーの処理

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

実は、thenのコールバック関数の処理中に例外が発生した場合、そのthenが返したPromiseは失敗します。例外を発生させる一番簡単な方法はthrowすることですので、throwしてみましょう。次のサンプルを見てください。この例ではどのPromiseで何が起こっているのか分かりやすいようにいちいち変数に入れています。


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は成功してp1.thenで登録された関数が呼ばれます。これの実行中に例外が発生したので、p1.thenが返したPromise返り(今回は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も失敗となります。直感的には、p1の失敗が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; }

つまり、p1の結果の値が素通りしてp2の結果となります。よってこの場合は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を返してみるのが今風ということです。