memolu

いろいろメモってます

ES6 - Promise を解説してみる

本連載について

本連載では少しづつES6について記載していこうと思います。 第三回はPromiseを学びます。 発表されてから久しいですが、まだ習得されていない方は今からでも勉強していきましょう。

Promise

Promiseとは非同期処理を制御するための機能です。
Aの処理が終わったらBの処理をしてCの処理をして...という流れを整理して書くために存在しています。 他のプログラミング言語にも存在する並列処理のデザインパターンに近い概念かと思われます。
つまり、処理の流れをパイプライン化させ、然るべきタイミングまで実行したい処理を後回しにできるようになります。

Promiseと呼ばれるとjQueryのDeferredなどに触れたことがある方ならば機能の予測はしやすいかもしれませんが、厳密には仕様が違いますので注意が必要です。

ブラウザサポート

ブラウザサポートについては以下をご確認ください。

http://kangax.github.io/compat-table/es6/

コールバック地獄

まずはPromiseを触る前に非同期処理で陥りがちな「コールバック地獄」と呼ばれるコードを見てみましょう。
これらはコードの見通しやメンテナンス性の観点から良しとされていません。
以下に簡単なコードにして記します。

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

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

上記で作成した関数timerは何秒後に関数を実行するかの秒数でdelayと、実行するコールバック関数をcallbackとして引数で取得します。 実行されるとsetTimeoutが起動しdelay秒後にcallbackを実行する、という関数です。

これを非同期処理のように実行すると、コールバック関数の中にコールバック関数がネストされているのが分かるかと思います。 もっと複雑なコードになるとネストが深くなり、コードの可読性・メンテナンス性が欠けたコードが生成されます。
これが上述した「コールバック地獄」です。
Promiseはこの問題の解決に適した機能になっています。

Promiseの使い方

さっそくPromiseを使って上述のコールバック地獄に陥ったコードを書きなおしてみます。
まだコードの意味がわからなくても問題ありません。コードが簡潔になっているかに注目して下さい。

function timer(delay){
    return new Promise(function(resolve, reject){
        if(/* 失敗時 */) {
            reject(new Error('エラー'));
            return;
        }
        setTimeout(function(){
            if(/* 成功時 */) {
                resolve('成功');
            }
        }, delay);
    });
}

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

関数timerの内容については後述いたします。 まずはコールバック地獄だった先ほどのコードに比べると、実行部分が軽量化されていることをご覧いただければと思います。
Promiseを使用することで非同期処理を簡潔に記述することができ、コールバック地獄も解消することが可能です。

Promiseの仕組み

続いて実際の記述方法、処理の流れなどを追ってみます。 Promiseを使うには、まずPromiseコンストラクタをnew演算子をつけて呼び出し、Promiseオブジェクトを生成します。
通常は生成されたPromiseオブジェクトをreturnすることで次の非同期処理に繋げるようにします。
下のコードはPromiseオブジェクトの生成する基本コードです。

function() {
    return new Promise(resolve, reject){
        if(/* 失敗時 */) {
            reject(new Error('エラー'));
            return;
        }
        if(/* 成功時 */) {
            resolve();
        }
    }
}

非同期処理は処理が成功したのか、あるいは失敗したのかで次の処理を行うのか判断しないといけません。 ajaxなどを触れたことがあればわかりますが、donefailがありました。
Promiseにもこのように状態を示すものが存在します。 新たに生成されたPromiseオブジェクトは自身が解決状態なのか失敗したのか、あるいは処理が行われていないのかを示す状態を保持しています。 これらはそれぞれ以下のようになります。

  • Fullfilled - 成功状態
  • Rejected - 失敗状態
  • Pending - 初期・処理中

Promiseオブジェクトの状態を操ることで次の処理に成功状態で渡すのか失敗状態として渡すのかを操作できるようになっています。Promiseオブジェクトの状態を操作するのはresoleve()reject()です。

Promiseオブジェクトは引数としてresolverejectという関数を2つとります。
resolve()は成功した際に実行される関数で、reject()は何らかのエラーが発生した際に実行するように記載しておきます。
Promiseオブジェクトは生成された段階では未解決状態になっており、resolve()``reject()のどちらかが実行されることで状態が変わります。
そのタイミングで次の非同期処理へフェーズが進行するのです。 この2つの関数どちらかを実行することで、Promiseオブジェクトの状態が切り替わり、次の処理へと非同期処理を実行し続けていくことができるのです。

この段階では細かい仕様はまだ少し置いておいて、以下のことだけ覚えておきましょう。

  • Promiseを使うにはPromiseコンストラクタをnew演算子付きで呼び出し、Promiseオブジェクトを生成する
  • 通常は生成されたPromiseオブジェクトをreturnすることで次の処理につなげていく
  • Promiseには処理が成功したかどうかを伝えるメソッドが存在する。resolve()が成功時で、reject()が失敗時にそれぞれ実行するように記述する。

Promise.resolve

Promiseオブジェクトの生成はnew Promise()を使いますが、実はショーカットのような生成方法が存在します。
それがPromise.resolve()Promise.reject()です。
Promise.resolve()は通常のPromise生成と比較すると以下のように置き換えられます。

// Promise.resolve()
Promise.resolve('hoge');

// new Promise()
new Promise(function(resolve, reject){
    resolve('hoge');
});

Promise.resolve()new Promise()をしたときのようにPromiseオブジェクトを返します。 そしてすぐにresolveが実行されてPromiseオブジェクトがfullfilled状態になり、次の処理に渡すことができるようになります。 記述も減るため、シンプルに実行するだけであればこのショートカットが機能します。
Promise.resolve()は渡される引数によって振る舞いが少し変わるため、こちらも解説します。

Promise.resolve()の振る舞い

Promise.resolve()は渡される引数によって振る舞いが変わります。
以下にどのように変わるのかを記載します。

  • Promiseオブジェクトが渡った場合 Promiseオブジェクトが渡された場合、受け取ったPromiseオブジェクトをそのまま返します。次の非同期処理は渡されたPromiseオブジェクトの状態で判断するということです。

  • thenableが渡った場合 上記にもあったhoge().then(/*処理1*/).then(/*処理2*/)のような記述がありました。
    then()については後述しますが、この値が渡された場合、Promiseオブジェクトのようなオブジェクト変換して、非同期処理に扱える形に変換します。
    例えばjQueryajaxで扱うjqXHRオブジェクトもthenableとして認識されます。以下で例を記載します。

var promise = Promise.resolve($.ajax('/path/to'));

promise.then(function(res) {
    console.log(res);
});

Promise.resolve()の引数としてajaxを渡しました。
返ってくるjqXHRオブジェクトをPromiseオブジェクトと似た形式にして変換し、then()に値まで渡しているのが分かるかと思います。
これがthenableが渡った際の振る舞いです。

  • 上記以外が渡った場合

上記以外の値が渡った場合は、渡された値を持ったままfullfilled状態のPromiseオブジェクトが返ってきます。 先ほどご紹介した以下のコードは、そのままこの仕様を物語っています。

// Promise.resolve()
Promise.resolve('hoge');

// new Promise()
new Promise(function(resolve, reject){
    resolve('hoge');
});

このようにPromise.resolve()は渡された値によって振る舞いが変わる点に注意してください。

Promise.reject

これはPromise.resolveとは反対に失敗状態(Rejected)としてPromiseを返すものです。
他の仕様は上記と同じです。

Promise.then

Promise.thenはPromiseがfullfilled状態になった場合に、次はどの処理を行うかと明示するものです。
Promiseはthen()というメソッドを使って非同期処理をチェーンのようにつないで処理を行います。gulpを使った方ですとpipe()などの形式は似ていることがわかるかと思います。

var promise = Promise.resolve('成功しました');

promise.then(function(str) {
    console.log(str); // 成功しました
});

上記のthen()は変数promiseに代入したpromiseオブジェクトがfullfilled状態になったため実行されました。

Promise.catch

Promiseオブジェクトがfullfilled状態になった場合に実行されるのがPromise.thenでした。
これに対しPromiseオブジェクトがrejected状態(失敗)になった場合に実行されるのがPromise.catchです。
つまり、Promiseオブジェクトでreject()が実行された場合は、Promise.catchが実行されます。

var promise = Promise.reject('エラーです');

promise.catch(function(str) {
    console.log(str); // エラーです
});

ここまでのまとめ

Promiseには状態が3つあります。

  • Fullfilled - 解決状態
  • Rejected - 失敗状態
  • Pending - 初期状態 or 処理中

この値を操作するのが以下の2つの関数でした。

  • resolve() - PromiseをPendingからFullfilledにする
  • reject() - PromiseをPendingからRejectedにする

そしてPromiseの状態が変わった際に実行したい処理を記載するには以下の2つです。

  • then() - Fullfilledだった場合
  • catch() - Rejectedだった場合

これらを踏まえて次は、非同期処理を連結する方法を解説します。

非同期処理の連結

ここまでの内容でPromiseの各メソッドなどの特徴を説明しました。
次は非同期処理をどう連結していくのか。またその仕様やデザインパターンについて解説していきます。
thenをいくつか繋げて使ってみます。Promise chainと呼ばれたりします。

function taskA() {
    console.log("Task A");
}
function taskB() {
    console.log("Task B");
}
function onRejected(error) {
    console.log("Catch Error: A or B", error);
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

上のコードを実行するとconsoleタブには以下のように出力されます。

Task A
Task B
Final Task

このようにPromiseオブジェクトがRejected状態にならない限りcatchは呼ばれません。
taskAでErrorが発生するとtaskBは実行されずcatch()まで処理はジャンプし、catchの後のfinalTaskはcatch後に実行されます。

非同期処理にエラーがあった場合

非同期処理にエラーがあった場合はどのように追随する処理が実行されるのか見てみましょう。

function taskA() {
    console.log("Task A");
    throw new Error("throw Error @ Task A");
}
function taskB() {
    console.log("Task B");// 呼ばれない
}
function onRejected(error) {
    console.log(error);// => "throw Error @ Task A"
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

taskAにerrorを記載しました。 これで実行するとコンソールはどうなるか見てみましょう。

Task A
throw Error @ Task A
Final Task

このようになります。
TaskAでエラーが発生したため、TaskBはスルーされcatch()まで処理が進みました。catch()もresolve()で返るため、finalTask()は実行されています。なんとなく処理の流れが見えているかと思います。

Promiseと非同期

ここで一つtips的な内容でPromiseの処理方針について解説します。
Promiseの処理や実行される順序を見ていると、同期的に処理が進んでいっているものと錯覚してしまいます。しかし、実際にはthen()で実行される処理は非同期になります。
この同期と非同期の違いとはなんでしょうか。
次の例を見てみましょう。

function onReady(callback) {
    var state = document.readyState;
    if(state === 'interactive' || state === 'complete') {
        callback();
    } else {
        window.addEventListener('load', callback);
    }
}

onReady(function(){
    console.log('load complete.')
});

console.log('start');

上記のコードではdocumentのロード時にcallbakとして登録した関数を実行するためのonReady関数を定義しています。 if文の処理では、document.readyStateを使用して、すでに読み込まれているかどうかを判断しています。onReady関数が実行時にすでにdocumentが読み込み完了している場合はcallback関数をそのまま実行し、まだだった場合はwindowオブジェクトにloadイベントにcallback関数を登録し、loadイベントが発火した際にcallbak関数が実行されるようにしました。
このif文で言うと、すでに読み込まれた際に実行されるcallbak()は同期処理です。それに対しwindowのloadイベントで実行されるcallbackは非同期処理になります。

前提としてjavascriptはシングルスレッド処理です。
物理的に一度に1つの処理しかできないため、全ての処理を同期的に処理してしまうと、その処理が終わるまで次に続く処理が待っている間に実行されません。この問題を解決するためにjavascriptでは非同期処理と呼ばれる処理方法が用いられています。
上記のコードの場合、onReady関数を実行した際に「documentが読み込み終わっていたらそのままcallbackも実行しておいてね。もしまだだったら読み込み終わったときに教えてくれるからそのときに処理してね」という書き方になります。ここの「読み込み終わったときに教えてくれる」「そのときに処理しておいてね」がまさに非同期処理になります。
シングルスレッドで一つの処理しかできないため、別の場所で読み込みが終わったタイミングだけ教えてもらうのです。そしてその時にはじめて処理が実行されるという振る舞いをします。

さらに、上記で書いたコードには問題があります。 if文の判定結果でconsole.log()の実行順序が変わってしまうのです。

// 同期処理:documentが読み込まれていた場合
load complete.
start

// 非同期処理:documentが読み込まれていなかった場合
start
load complete.

このように処理の順番が逆転してしまいます。こうなる原因は一度非同期としてイベントリスナーなどに登録されると、イベントのキューとしては一番最後に登録されます。
そのため、先に他の処理を実行し終わってからイベントリスナーに登録したキューが実行されるからです。
すると、今回のケースの場合、windowのloadイベントに登録されたcallbackはキューが一番最後に登録されるため、startが先に出力されるということです。
このように処理の問題が入れ替わる問題が発生するため、基本的には非同期コールバックを同期的に呼び出してはいけないという慣例があります。いくつか理由が存在しますが、意図しない処理の流れになってしまったり、スタックオーバーフローを懸念することなどが挙げられます。
これを解決するために処理を全て非同期にしてあげましょう。ここではsetTimeout()を使います。

function onReady(callback) {
    var state = document.readyState;
    if(state === 'interactive' || state === 'complete') {
        setTimeout(callback, 0);
    } else {
        window.addEventListener('load', callback);
    }
}

onReady(function(){
    console.log('load complete.')
});

console.log('start');

setTimeout(callback, 0);を使うことで非同期処理にします。こうすることでいったん処理は下まで実行され、setTimeoutのキューは処理の最後に回ります。consoleの出力される情報も状況によって変わることもなくなりました。
このように非同期処理と同期処理が混在すると、様々な問題を引き起こしやすいため、あえて非同期処理にするケースが存在します。Promiseもこれと同じで内部処理的に全て非同期に実行される仕様になっています。

Promise.all

話はもどって、Promiseのメソッドについて解説を続けます。
Promise.allは複数のpromiseオブジェクトに対して処理するための機能で、Promise.all配列として受け取ったPromiseオブジェクトが全てfullfilled状態になった場合にresolve()となります。
以下のコードをご覧ください。

function timer(delay) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve(delay);
        }, delay);
    });
}
Promise.all([
    timer(1),
    timer(32),
    timer(64),
    timer(128)
]).then(function(values) {
    console.log(values); // [1,32,64,128]
});

timer関数は4回呼ばれておりますが、Promise.allにより続くthen()が実行されるのは登録した4つのtimer関数から返ってきたPromiseがfullfilled状態になった場合です。 また処理もPromise.allは配列に渡された処理を順番に実行するのではなく、ここでも非同期で全てほぼ同時に実行される点に注意してください。

Promise.race

Promise.allは配列として渡された処理が全てfullfilledになった場合以降の処理に進むものでした。それに対しPromise.raceは渡された配列内の処理が、一つでもfullfilledになった場合、処理を先に進めます。先ほどの処理をPromise.raceで書きなおしてみまししょう。

function timer(delay) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve(delay);
        }, delay);
    });
}
Promise.race([
    timer(1),
    timer(32),
    timer(64),
    timer(128)
]).then(function(values) {
    console.log(values); // [1]
});

今回は1msを登録した最初の処理が終わったタイミングでthen()が実行されました。この場合、配列に登録した他の処理は実行されているのでしょうか。 以下の処理で検証してみます。

function timer(delay) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            console.log(delay);
            resolve(delay);
        }, delay);
    });
}
Promise.race([
    timer(1),
    timer(32),
    timer(64),
    timer(128)
]).then(function(values) {
    console.log(values); // [1]
});

これを実行すると、32、64,128という数値もconsoleに出力されるかと思います。
つまり、Promise.raceはどれかひとつがfullfilledになった時点で次のthen()に処理が移りますが、他の処理をキャンセルはしていないということです。 そもそもPromiseにはキャンセルと言う概念が存在しません。必ずresolve()もしくはreject()になる前提で内部処理が書かれています。そのため状態が一時停止のようになるような処理には向いていないとされます。

まとめ

いくつか例を出しながらPromiseの仕組みについて解説してみましたが、思ったよりも量が増えてしまいました。非同期処理はそれだけ奥が深いと同時に覚えると強力なものであるということが分かるかと思います。最初はやや小難しいですが、いくつかコードを書くことで慣れも出てくると思いますので、自分の書いたコードのどれかとPromiseに置き換えてみたりすることをおすすめします。