Corredor

ウェブ、プログラミングの勉強メモ。

Angular.js の $q から Promise を覚えた

初めてのJavaScript 第3版 ―ES2015以降の最新ウェブ開発

初めてのJavaScript 第3版 ―ES2015以降の最新ウェブ開発

  • 作者: Ethan Brown,武舎広幸,武舎るみ
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2017/01/20
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

ES2015 (ES6) から登場した Promise を今更覚えたというお話。

Promise とは

Promise、もしくは Promise パターンとは、非同期処理を同期的に扱うための仕組みで、非同期に処理する関数を Promise というオブジェクトで包み込んでおくことで、「ある非同期処理が終わったら、次の処理を行う」といった「約束 (Promise)」を取り付けることができるようになるもの。

ES6 において仕様が策定されており、Promise チックなことができるライブラリのほとんどは、この仕様に準拠して実装されている。

Promise がない時代はどうしていたか … コールバック地獄

これまで Ajax など非同期処理の中で「アレが終わったらコレする」をやろうとすると、「アレ」の処理に対して、終わった後に行わせたい「コレ」関数を渡しておき、処理の最後に呼び出す、ということをしてやらないといけなかった。このような扱いをコールバックと呼ぶ。要するに以下のようなコードにしないと、処理の順番を管理できなかったのだ。

function hoge() {
  // hoge() の何か非同期処理…
  
  // hoge() が終わったら行わせたい処理:fuga()
  // fuga() には hoge() の実行結果を渡したい
  (function fuga(hogeResult) {
    // fuga() の何か次の処理…
    
    // fuga() の後にやらせたい処理:foo()
    // foo() には fuga() の実行結果を渡したい
    (function foo(fugaResult) {
      // foo() の何か処理……
    })(fugaResult);
  })(hogeResult);

見て分かるとおりインデントが深くなっていく。関数をそれぞれ宣言しておいて平たく並べたとしても、「どの関数からどの関数を呼ぶ (コールバック) すればいいのか」を管理する必要があり、大変だ。

function hoge() {
  // hoge() の何か非同期処理…
  
  // hoge() が終わったら行わせたい処理:fuga()
  // fuga() には hoge() の実行結果を渡したい
  fuga(hogeResult);
}

function fuga(hogeResult) {
  // fuga() の何か次の処理…
  
  // fuga() の後にやらせたい処理:foo()
  // foo() には fuga() の実行結果を渡したい
  foo(fugaResult);
}

function foo(fugaResult) {
  // foo() の何か処理……
}

// 全て準備したら最初に呼ぶ関数を実行する
hoge();

このとおり。これが、俗にいう「コールバック地獄」と呼ばれる状態のコードだ。

Promise は如何にこの問題を解決するか

じゃあ Promise で実装するとどういう風になるのか、見てもらおう。

function hoge() {
  return new Promise(function(resolve, reject) {
    // hoge() の何か非同期処理…
    // 処理結果として hogeResult を返す
    resolve(hogeResult);
  });
}

function fuga(inputData) {
  // 何か事前処理したければココに書く
  // (基本的には同期処理のみ。非同期処理の順序を守るためにこの書き方をするので…)
  return new Promise(function(resolve, reject) {
    // fuga() の何か次の処理…
    // inputData をアレコレして fugaResult を返す
    resolve(fugaResult);
  });
}

function foo(inputData) {
  return new Promise(function(resolve, reject) {
    // foo() の何か処理……
    if(inputData > 1) {
      // inputData をアレコレして結果となる fooResult を返す
      resolve(fooResult);
    }
    else {
      例外があった場合はエラーを出力する
      reject('inputData は1以上でないとダメ');
    }
  });
}

// 実行する際は以下のように書く
hoge()
  .then(function(hogeResult) {
    // 何か前処理が必要ならココに…
    // fuga() を呼ぶ
    return fuga(hogeResult);
  })
  .then(function(fugaResult) {
    // foo() を呼ぶ
    return foo(fugaResult);
  })
  .catch(function(errorMessage) {
    // hoge()・fuga()・foo() のどこかでエラーが起きたらこのブロックに入る
    console.log(errorMessage);
  });

注目してほしいのは以下の点。

  • hoge()fuga()foo() それぞれの関数内に、お互いの関数名などが登場せず、それぞれの関数が独立している
  • 実行する時のコードを簡略化して読むと、hoge().then( fuga() ).then( foo() ) といった構成になっており、「hoge() したら (Then) fuga() する」というように自然言語に近い書き方で読みやすい。いくら処理を繋げてもインデントが増えない

出来ることは最初のコードと同じだが、より簡潔に、柔軟に非同期処理を扱えるようにしてくれるのが Promise というオブジェクトなのである。

Promise のお約束

Promise を使うためのお約束は簡単。

先ほどの例で、それぞれの関数は、中に function を持った Promise オブジェクトを return 返していることが分かるだろう。つまり、処理を必ず Promise オブジェクトで包み込むことで、

  • 「Promise の中に書かれた処理が終わるまで、Promise 様が待ってあげましょう」
  • 「Promise の中の処理が終わったら、then() メソッドで次の処理に繋げられるようにしましょう」

というルールになっている。then() メソッドを持つオブジェクトは Thenable オブジェクトと呼ばれる。

なお、その関数が返却する値は、return hogeResult; ではなく resolve(hogeResult); というように、resolve() というメソッドを使う。resolve() に渡した値は、その後に繋ぐ .then(function(hogeResult) { }) の部分で受け取れる仕組みになっているので、

hoge()
  .then(function(hogeResult) {
    return fuga(hogeResult);
  })
  .then(function(fugaResult) {
    return foo(fugaResult);
  });

このような芸当が可能になっている。

関数化しているそれぞれの部分を中に展開すると、Promise が必ず返されていることが直感的に分かるようになるだろう。

// 元々の hoge() 処理
new Promise(function(resolve, reject) {
  resolve(hogeResult);
})
  .then(function(hogeResult) {
    // 元々の fuga() 処理
    return new Promise(function(resolve, reject) {
      resolve(hogeResult);
    });
  })
  .then(function(fugaResult) {
    // 元々の foo() 処理
    return new Promise(function(resolve, reject) {
      resolve(fugaResult);
    });
  });

なお、reject()resolve() と同様に、値を次のメソッドに返せるが、then() では受け取れず、catch() メソッドで受け取れる (厳密には then() の第2引数に設定した関数で拾えたりするのだが、話を簡単にするためココでは触れない)。

簡単にまとめると、resolve() は Promise における returnreject() は Promise における throw と思って良い。Promise は return (= resolve()) された値を then() で受け取って次の処理に受け渡すことができるし、throw (= reject()) された内容は try / catchcatch 句ならぬ catch() メソッドで受け取れる、というワケだ。

ES2015 以前から Promise を実現していたライブラリ

最近はどのブラウザも Promise に対応するようになったが、Promise の仕様を策定しているような段階の時代は、当然ブラウザ実装はなかったので、各ライブラリが頑張って Promise チックな非同期処理の管理を行っていた。

よく知られているのは jQuery の Ajax 処理だろうか。jQuery v1.4 ぐらいまでは以下のような書き方をしていた。

$.ajax{({
  type: 'POST',
  url: url,
  data: data,
  success: function(data, textStatus, jqXHR) {
    // Try 区内の最後に呼びたい処理と同等
    alert('Success!');
  },
  error: function(data) {
    // Catch 句と同等
    alert('Error!');
  },
  complete: function(jqXHR, textStatus) {
    // Finally 句と同等
    alert('Complete!');
  }
});

これはまだ Promise な書き方ではなく、事前にコールバック関数 (処理が終わったら呼びたい関数) を連想配列で渡しているだけである。

これが jQuery v1.5 になると以下のようにメソッドチェーンが可能になった。

$.ajax({
  url: url
}).success(function(data) {
  // Try 区内の最後に呼びたい処理と同等
  alert('Success!');
}).error(function(data) {
  // Catch 句と同等
  alert('Error!');
}).complete(function() {
  // Finally 句と同等
  alert('Complete!');
});

ついでに言うと jQuery v1.8 からはメソッドの名前が変わった。

$.ajax({
  url: url
}).done(function(data) {
  // Try 区内の最後に呼びたい処理と同等
  alert('Done! ← Success');
}).fail(function(data) {
  // Catch 句と同等
  alert('Fail! ← Error');
}).always(function() {
  // Finally 句と同等
  alert('Always! ← Complete!');
});

こうした書き方ができるようになったのは、jQuery が jQuery.Deferred() という仕組みを用意したからで、文字どおり、指定のコールバック関数の実行を延期 (Deferred) させられるようになったから、メソッドチェーンとして書けるようになった、ということ。

jQuery.Deferred() には .then() メソッドもあるので、こうした Promise にかなり近い書き方もできる。

$.ajax({
  url: "ajax.html",
}).then(
  function(data) {
    alert('Success!');
  },
  function(data) {
    alert('Error!');
  }
);

まだちゃんと紹介していなかったが、Promise と同じく、then() メソッドの第2引数に渡した関数が、直前の処理のエラーをキャッチしたりできる。

なお、jQuery.Deferred() が実現する Promise は、ES6 で策定されている Promise オブジェクトの仕様とは厳密には異なるため、他の Promise 処理と連結する際は変換が必要だったりする。

他にも、「Dojo」というライブラリや、後述する「Q」ライブラリだったり、new Promise() を IE11 でも解釈させる Polyfill があったりと、ブラウザが実装するまで自前で Promise を何とかするライブラリが数多くあった。

自前で Promise しようと思ったら… setTimeout()

ところで、「ある関数の終了を監視して、その関数の処理が終わったら次の関数を呼び出す」という処理は、setTimeout() なんかを使えば書けるんじゃないだろうか。

実は既存のライブラリでも、setTimeout() によって処理の終了を待っていたりする (自身が動作する環境を判別し、Node.js で動作していれば別のやり方を使ったりしていて、他にやりようがなければ setTimeout() で制御する、というやり方が多い)。自前でやろうとすればできなくもないが、それを規格化してネイティブで対応するようにしたのが Promise というワケだ。

「Q」ライブラリと Angular.js の「$q」サービス

「Q」ライブラリのことは、Angular.js に組み込まれている「$q」サービスから知った。Promise が非同期処理の順序 (キュー) を管理するから「Q」なのだろうか。1文字なので検索しづらい。w

ES6 で策定された new Promise() するパターンとは異なる、「Deferred/Promise パターン」という実装パターンを採用しており、書き方が若干違う。この書き方だと、既存のメソッドを Promise なメソッドに置き換えるのが容易だったりするかもしれない。

まずは Promise オブジェクトを返す関数を書いてみる。ココでは Angular.js の「$q」サービスを使う前提なので、$q.defer() などと書いているが、「Q」ライブラリを直接使う場合は Q.defer() のように読み替えてもらえば良い。

function hoge() {
  // コレが「new Promise()」の代わりのようなモノ。$q に Deferred オブジェクトを生成させる
  var deferred = $q.defer();
  
  // 何か処理…
  
  // ただの resolve() ではなく、deferred.resolve() に受け渡したい値を持たせる
  deferred.resolve(hogeResult);
  
  // Promise オブジェクトは Deferred オブジェクトの中に内包されているので、
  // コレを return することで、この関数 hoge() は Promise を返す関数になる
  return deferred.promise;
}

通常の関数の最初に var deferred を宣言し、返したい値は deferred.resolve() に詰め、return するのは deferred.promise にする。先ほどの new Promise() で書く ES6 Promise と比べると、以下のような違いがある。

  • 「Q」ライブラリの場合は Promise オブジェクトを内包する「Deferred」オブジェクトが主体となって、Promise オブジェクトを「操作」している。
  • 「Q」ライブラリを使う方が、new Promise(function(resolve, reject) { }) 内に処理を書くより、関数内のインデントが1段低く済ませられている。

ES6 の Promise の場合は、Promise オブジェクトを生成し、そのコンストラクタに引数として渡す関数が、非同期処理をラップしたものでないといけない。そのため、どうしても return new Promise(function... と書いてからの行が伸びていく傾向にある。hoge() 関数内のインデントが Promise 生成によって1段階増えてしまうのも違和感が残る。

一方、「Q」ライブラリの場合は、関数の最後に return deferred.promise; とさえすれば良く、hoge() 関数全体を Promise オブジェクト内の関数のように扱えるので、他の同期的な処理のメソッドと見た目に差異が生まれにくい。既存の関数をそのまま Promise 化しやすいと思う。


Promise を理解する上で、このような「ライブラリによる実装方法の差異」があったり、そもそも仕様がコレという一つに決めきれていなかったり (そういう時代が割と長くて、ググった時に情報が混在してしまっている)、といったことが障壁になっていると思う。

だからまずは「ES2015 で策定されている Promise の方法と実装方法」だけ学び、まずは Promise 単体の標準仕様を押さえておく。それから jQuery Deferred や Q ライブラリといった、「Deferred が Promise を操作する」というパターンの実装方法を学ぶと、概念的に混乱が生じにくいと思う。