ECMAScript 2015の新機能 第1回 Promise 1

この記事ではES6の新機能である非同期処理を扱うPromiseオブジェクトの概要と、その実装について解説します。第1回目ではPromiseオブジェクトの特徴と、基本的な使い方について触れます。

発行

著者 山田 順久 フロントエンド・エンジニア
ECMAScript 2015の新機能 シリーズの記事一覧

Promiseとは何か

Promiseは非同期処理を扱うための仕組みのひとつです。

例えば非同期処理を行う関数は、呼び出しに応じて、将来返すはずである未知の値の代わりに、Promiseオブジェクトというものを、その場での戻り値としておくことができます。そして、いつか結果がわかったときに、戻り値として渡しておいたPromiseを通じて、本来渡すべきだった値を返すことができるようになります。

フロントエンドの実務におけるPromiseの用途としては、XHR*やアニメーションの終了タイミングを知らせることなどに使えるでしょう。

*注:XHR

XHRに関しては「こわくないAjax:Ajaxの仕組み」などの記事も参照してください。

すでにjQueryが備えているjQuery.Deferred*(厳密にはES6のPromiseとは違う仕様ですが)の機能を通じて、似たような挙動に触れている方も多いかもしれません。

*注:jQuery.Deferred

jQuery.Deferredに関しては「jQuery deferredの使い方」シリーズなどの記事も参照してください。

この記事ではES6仕様としてのPromiseの紹介をしていきたいと思います。

Promiseの利点

まずはPromiseの利点を見ていきましょう。非同期な処理が終わったら別の処理を行うコードを書きたいといった場合、コールバック関数を引数に渡しておく方法が挙げられます。次のコードはdelayミリ秒後にcallback関数を呼び出すtimer関数を定義した例です。

function timer (delay, callback) {
  setTimeout(function () {
    callback();
  }, delay);
}

こうしたコールバックを用いたスタイルは関数が奥深くへとネストしていき、Callback Hell(コールバック地獄)という状態に陥ってしまう話を耳にすることもあります。

timer(1000, function () {
  timer(2000, function () {
    timer(3000, function () {
      timer(4000, function () {
        ...
      });
    });
  });
});

次のようにコールバック関数を参照渡しにしていれば、ネストが深くなる問題は避けられます。しかし、これでは、どの順番に処理が呼ばれるのかを追うのに少し苦労してしまいそうです。

function onFirstTimerCalled () {
  timer(2000, onSecondTimerFinished);
}

function onSecondTimerFinished () {
  timer(3000, onThirdTimerFinished);
}

function onFourthTimerFinished () {
  timer(4000, ...);
}

timer(1000, onFirstTimerFinished);

もしPromiseを使っていたら、これを次のように書くことができるでしょう。

timer(1000)
  .then(timer(2000))
  .then(timer(3000))
  .then(timer(4000))
  .then(...);

1000ミリ秒たったら次は2000ミリ秒数えて、その次は3000ミリ秒数えて……というコードの意図を、読んで理解しやすくなったのではないでしょうか。非同期な処理の呼び出しと、それを繋いでいく接続部分が分かれていて、関数の中に関数が入れ子になっていく構造よりも、すっきりして見やすくなっているかと思います。

そのほかにもPromiseの特徴として挙げられるものに、次のような点があります。

  • 一度だけその状態を成功(fulfilled)、または失敗(rejected)のどちらかへ遷移することができる
  • すでに状態遷移を済ませてしまったPromiseに対して、コールバック関数を後から追加した場合であっても、コールバック関数は正しく呼ばれる
  • 複数のPromiseの状態をまとめて監視して、それが終わったら何かするということができる

これらの特徴があると、どのようなことができるでしょうか。例えば複数のデータをXHRで取得していて、取得がすべて終わったら別の処理を行いたい場合に、Promiseであれば複数のXHRの成否をまとめて通知することができますし、一度成功を通知したら状態はそれきり固定されるので、成功したという状態は以後不変であることが保証されます。

また、イベントのコールバックと違って、タイミングによっては、イベントがすでに終わっていて、追加したコールバックが呼ばれないかもしれないということを、気にしなくてよいというメリットもあります。

使用できる環境

それではPromiseを使える環境をチェックしてみましょう。

caniuse.comによればFirefoxは29から、Chromeは33、iOS Safariは8、Android Browserは4.4.4からの対応となっています。IEについては現状Windows 10 Technical Preview版でのサポートとなっており、この記事を書いているタイミングでは、まだブラウザ実装として全面的に普及しているとは言えません。対象ブラウザを絞ったプロジェクトでは使えるといったところでしょうか。

この点についてはes6-promiseのようなPolyfillや、Promise仕様の実装をしながら独自の拡張も加えたbluebirdのようなライブラリを使うといった方法をとることもできます。

また、Node.js環境においてはバージョン0.11.13から使用可能となっています。

Promiseオブジェクトの生成

Promiseを使うためには、まずPromiseコンストラクタをnew演算子で呼び出して、初期化されたPromiseオブジェクトを得ることから始まります。次のコードはその一例です。

var promise = new Promise(function (resolve, reject) {
  // ここに非同期処理を書く

  if (/* 失敗した場合 */) {
    reject(new Error('エラーデス!!'));
    return;
  }

  if (/* 成功した場合 */) {
    resolve('成功です');
  }
});

Promiseコンストラクタは引数として関数を受け取ります。この関数の中に非同期処理を書いて、それを成功とみなす場合にはresolveを呼び出し、失敗とみなす場合にはrejectを呼び出します。

非同期処理のもう少し具体的な例として、XHRをPromiseで書いたものが次のコードです。

function get (url) {
  return new Promise(function (resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', url, true);

    req.addEventListener('load', function (e) {
      if (req.status === 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    });

    req.addEventListener('error', function (e) {
      reject(new Error(req.statusText));
    });

    req.send();
  });
}

あるいは単純な動作確認をする用途なら、setTimeoutを使っただけのコードでもよいです。

function timer (delay) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve();
    }, delay);
  });
}

ここまでの内容ではまだ、処理が終わったらいつか教えるよという約束だけを返したに過ぎません。その連絡を受けて何をするのかといった処理は、Promiseオブジェクトが提供する機能を使って追加していくことができます。それは追って紹介していきます。

Promise.resolve

Promiseから直接呼び出せるPromise.resolveもまた、new Promise(...)のようにPromiseオブジェクトを返します。こちらは与えられる引数によって、返されるPromiseオブジェクトの状態は違うものになります。

Promiseオブジェクトが渡された場合

まず、Promiseオブジェクトが渡された場合、これは受け取ったPromiseオブジェクトをそのまま返します。

thenableが渡された場合

次に、thenableが渡された場合です。thenableというのは、thenメソッドを持ったPromiseのようなオブジェクトのことです。thenメソッドについては後述しますが、このメソッドを持ったオブジェクトをPromiseオブジェクトに変換して扱うことができるようにします。

jQueryのjquery.ajax()などが返すjqXHRオブジェクトもjQuery.Deferredのthenメソッドを持っているためthenableとして挙げることができます。

次のコードはjqXHRオブジェクトをPromiseオブジェクトに変換する例です。

var promise = Promise.resolve($.ajax('/echo/foo'));

promise.then(function (res) {
  console.log(res); // => foo
});

しかし、thenableはあくまでPromiseのようなオブジェクトです。jQueryのDeferredおよび、PromiseはES6 Promisesに完全に準拠した実装ではありません。そのためES6 Promisesと完全に同じ挙動を示すわけではないことに注意してください。この点に関しては、次回、解説する予定です。

それ以外の値が渡された場合

そして最後に紹介するパターンとして、それ以外の値の場合は、渡された値をもってfulfilled(成功)になったPromiseオブジェクトを返します。

したがって、次に示した2つのコードが生成する2つのPromiseオブジェクト(promiseApromiseB)は、両方とも'Hello!'の値で、fulfilledの状態にあるという点において同様です。

var promiseA = Promise.resolve('Hello!');

var promiseB = new Promise(function (resolve, reject) {
  resolve('Hello!');
});

引数の省略も可能で、その場合には結果となる値を持たないfulfilledなPromiseオブジェクトを返します。

引数と返されるオブジェクトの状態

以上のことをまとめると、次のようになります。

与えられた引数 返されるオブジェクトの状態
Promiseオブジェクト 受け取ったPromiseオブジェクトをそのまま返す
thenableオブジェクト thenableオブジェクトをPromiseオブジェクトに変換して返す
それ以外の値 渡された値をもってfulfilledになったPromiseオブジェクトを返す
省略 結果となる値を持たないfulfilledなPromiseオブジェクトを返す

3つの値(オブジェクト)、あるいは省略することによって、返されるオブジェクトも変化します。

コラム:jQuery.DeferredとPromise.resolve

jQuery.Deferredをthenableとして扱い、Promiseに変換した場合にどんな違いがあるのか、今後紹介していく機能をちょっと先取りしたコードで例示します。少し先取りした内容ですので、これを読んですぐに理解できなくても大丈夫です。

次のコードではjQuery.Deferredを使って生成したPromiseに対して最初のthenに渡した関数内でthrowしています。

var thenable = $.Deferred().resolve('OK').promise()
  .then(function() {
    console.log(res);
    throw new Error('NG');
  });

これをPromise.resolveでPromiseへ変換して、Promiseのcatchでエラーを拾おうと試みてもうまくはいきません。catchに渡してあるコールバック関数は呼ばれずに、ブラウザ側へUncaught Errorが渡ってしまいます。

Promise.resolve(thenable)
  .catch(function(err) {
    // このコールバック関数が呼ばれることを期待しているが実際には呼ばれない
    console.error('エラーデス!!', err);
  });

コードの実行結果 1

このコードを実行した結果、catchのコールバックが呼ばれずブラウザにエラーが渡ってしまっている。

おそらくですが、thenのコールバック内で例外がthrowされた場合に、thenが返しているPromiseオブジェクトもその例外をもってrejectedとなるという挙動が再現できていないのではないかと考えられます。

もちろん、ES6 Promisesであればこの点は大丈夫です。

var promise = Promise.resolve('OK')
  .then(function(res) {
    console.log(res);
    throw new Error('NG');
  });

Promise.resolve(promise)
  .catch(function(err) {
    console.error('エラーデス!!', err);
  });

コードの実行結果 2

ES6 Promiseの場合は、catchのコールバックが呼ばれている。

Promise.reject

Promise.rejectPromise.resolveとは反対に、渡された値をもってrejected(失敗)になったPromiseオブジェクトを返します。

Promise.prototype.then

次はPromiseの処理が終わったら何をするかという処理を追加する方法を紹介していきます。

thenメソッドはPromiseオブジェクトがfulfilledとなったときに呼ばれる処理を追加します。引数として関数を渡しておき、その第一引数にはPromise内の処理でresolveに渡した値が与えられます。

例えば、次のコードで変数promiseresolve('成功です')によってfulfilledになるか、すでにfulfilledであった場合、このコールバック関数が受け取る引数resには実引数として'成功です'が渡ってくることになります。

promise.then(function (res) {
  console.log('Response: ', res); // 'Response: 成功です'
});

Promise.prototype.catch

catchメソッドも同じくPromiseオブジェクトから利用できるメソッドです。こちらはPromiseオブジェクトがrejectedとなったときに呼ばれる処理を追加するために使います。

例えば、次のコードで変数promisereject(new Error('エラーデス!!'))によってrejectedになるか、すでにrejectedであった場合、このコールバック関数が受け取る引数errには実引数としてnew Error('エラーデス!!')で生成されたエラーオブジェクトが渡ってくることになります。

promise.catch(function (err) {
  console.log('Error: ', err.message); // 'Error: エラーデス!!'
});

コラム:予約語のcatch

ES3仕様ではcatchのような予約語をプロパティ名として使うことができません。そのためIE8以下の環境では、たとえPolyfillを用いたとしてもコードの実行に問題が生じます。これを回避するためには['catch']と書くことで同等の処理を表現できます。CoffeeScriptを使用している場合には自動的に["catch"]への置換が行われています。

まとめ

今回はPromiseの紹介と、その基礎的な使い方を説明しました。Promiseを使うと、非同期な処理の呼び出しと、そのハンドリングを行っている部分の境界がthencatchといったメソッドを挟むことでわかりやすくなり、コードの見通しがよくなります。

次回は冒頭の例で登場していたようなPromiseを繋げて使う方法や、複数のPromise内処理の完了をまとめて検知するなど、より複雑な使い方について紹介していきます。