JavaScriptのPromiseについて理解する

はじめに

JavaScriptのPromiseは理解するのが難しいことがあります。そこで、私がPromiseを理解する方法を書き留めたいと思います。

Promiseの理解

Promiseとは簡単に言うと:

「あなたが子供だと想像してください。お母さんが来週新しい携帯電話を買ってくれると約束しました。」

来週までその携帯電話を手に入れるかどうかはわかりません。お母さんは本当に新しい携帯電話を買ってくれるかもしれませんし、買ってくれないかもしれません。

それが約束です。約束には三つの状態があります。それらは:

  1. Pending(保留中):あなたはその携帯電話を手に入れるかどうかわからない
  2. Fulfilled(履行済み):お母さんは嬉しいので、あなたに新しい携帯電話を買ってくれます
  3. Rejected(拒否された):お母さんは悲しいので、携帯電話を買ってくれません

Promiseの作成

これをJavaScriptに置き換えてみましょう。

// ES5: パート1

var isMomHappy = false;

// Promise
var willIGetNewPhone = new Promise(
    function (resolve, reject) {
        if (isMomHappy) {
            var phone = {
                brand: 'Samsung',
                color: 'black'
            };
            resolve(phone); // 成功
        } else {
            var reason = new Error('mom is not happy');
            reject(reason); // 拒否
        }

    }
);

コード自体が非常に表現力豊かです。

以下は、Promiseの構文が通常どのように見えるかです:

// Promiseの構文はこのようになります
new Promise(function (resolve, reject) { ... } );

Promiseの利用

Promiseができたので、それを利用してみましょう:

// ES5: パート2

var willIGetNewPhone = ... // パート1から続く

// Promiseを呼び出す
var askMom = function () {
    willIGetNewPhone
        .then(function (fulfilled) {
            // やった、新しい携帯を手に入れた
            console.log(fulfilled);
             // 出力: { brand: 'Samsung', color: 'black' }
        })
        .catch(function (error) {
            // あら、母が買ってくれなかった
            console.log(error.message);
             // 出力: 'mom is not happy'
        });
};

askMom();

例を実行して結果を見てみましょう!

デモ: https://jsbin.com/nifocu/1/edit?js,console

Promiseの連鎖

Promiseは連鎖可能です。

例えば、あなたが子供として、友達に母が新しい携帯電話を買ってくれたら約束して、それを見せると言いました。

それは別の約束です。書いてみましょう!

// ES5

// 2番目の約束
var showOff = function (phone) {
    return new Promise(
        function (resolve, reject) {
            var message = 'Hey friend, I have a new ' +
                phone.color + ' ' + phone.brand + ' phone';

            resolve(message);
        }
    );
};

注: 上記のコードは以下のように短縮できます:

// 短縮する

// 2番目の約束
var showOff = function (phone) {
    var message = 'Hey friend, I have a new ' +
                phone.color + ' ' + phone.brand + ' phone';

    return Promise.resolve(message);
};

Promiseを連鎖させてみましょう。あなたが子供として、willIGetNewPhoneの約束が終わった後でのみshowOffの約束を開始できます。

// 約束を呼び出す
var askMom = function () {
    willIGetNewPhone
    .then(showOff) // ここで連鎖させる
    .then(function (fulfilled) {
            console.log(fulfilled);
         // 出力: 'Hey friend, I have a new black Samsung phone.'
        })
        .catch(function (error) {
            // あれ、母は買ってくれなかった
            console.log(error.message);
         // 出力: 'mom is not happy'
        });
};

このようにして、Promiseを連鎖させることができます。

約束は非同期です

約束は非同期です。約束を呼び出す前後にメッセージをログに記録してみましょう。

// 約束を呼び出す
var askMom = function () {
    console.log('before asking Mom'); // 前にログを記録
    willIGetNewPhone
        .then(showOff)
        .then(function (fulfilled) {
            console.log(fulfilled);
        })
        .catch(function (error) {
            console.log(error.message);
        });
    console.log('after asking mom'); // 後にログを記録
}

予想される出力の順序は何ですか?次のように予想するかもしれません:

1. before asking Mom
2. Hey friend, I have a new black Samsung phone.
3. after asking mom

しかし、実際の出力順序は次のとおりです:

1. before asking Mom
2. after asking mom
3. Hey friend, I have a new black Samsung phone.

お母さんの約束(新しい携帯電話)を待っている間、遊びをやめることはありません。それが非同期と呼ばれるものです:コードは結果を待たずに実行されます。約束の結果を待つ必要があるものは、.thenに入れられます。

以下はES5での完全な例です:

// ES5: 完全な例

var isMomHappy = true;

// Promise
var willIGetNewPhone = new Promise(
    function (resolve, reject) {
        if (isMomHappy) {
            var phone = {
                brand: 'Samsung',
                color: 'black'
            };
            resolve(phone); // 履行された
        } else {
            var reason = new Error('mom is not happy');
            reject(reason); // 拒否
        }

    }
);

// 2番目のPromise
var showOff = function (phone) {
    var message = 'Hey friend, I have a new ' +
                phone.color + ' ' + phone.brand + ' phone';

    return Promise.resolve(message);
};

// Promiseを呼び出す
var askMom = function () {
    willIGetNewPhone
    .then(showOff) // ここでチェーンする
    .then(function (fulfilled) {
            console.log(fulfilled);
            // 出力: 'Hey friend, I have a new black Samsung phone.'
        })
        .catch(function (error) {
            // あらあら、母が買ってくれない
            console.log(error.message);
            // 出力: 'mom is not happy'
        });
};

askMom();

ES5, ES6/2015, ES7/NextにおけるPromises

ES5 – 大多数のブラウザ

デモコードはBluebird Promiseライブラリを含めることでES5環境(すべての主要ブラウザ + NodeJs)で動作します。これはES5がPromiseを標準でサポートしていないためです。もう一つ有名なPromiseライブラリはKris KowalによるQです。

ES6 / ES2015 – モダンブラウザ、NodeJs v6

デモコードはそのままで動作します。これはES6がPromiseをネイティブにサポートしているためです。さらに、ES6の機能を使うことで、アロー関数を用いてコードをさらに簡潔にし、constletを使用することができます。

以下はES6コードの完全な例です:

//_ ES6: 完全な例_

const isMomHappy = true;

// Promise
const willIGetNewPhone = new Promise(
    (resolve, reject) => { // アロー関数
        if (isMomHappy) {
            const phone = {
                brand: 'Samsung',
                color: 'black'
            };
            resolve(phone);
        } else {
            const reason = new Error('mom is not happy');
            reject(reason);
        }

    }
);

// 2番目のPromise
const showOff = function (phone) {
    const message = 'Hey friend, I have a new ' +
                phone.color + ' ' + phone.brand + ' phone';
    return Promise.resolve(message);
};

// Promiseを呼び出す
const askMom = function () {
    willIGetNewPhone
        .then(showOff)
        .then(fulfilled => console.log(fulfilled)) // アロー関数
        .catch(error => console.log(error.message)); // アロー関数
};

askMom();

varはすべてconstに置き換えられており、function(resolve, reject)(resolve, reject) =>と簡略化されています。これらの変更により、いくつかの利点が得られます。

ES7 – Async/Await

ES7ではasyncawaitの構文が導入されました。これにより、非同期処理が.then.catchなしで理解しやすくなります。

ES7の構文で例を書き換えると以下のようになります:

// ES7: 完全な例
const isMomHappy = true;

// Promise
const willIGetNewPhone = new Promise(
    (resolve, reject) => {
        if (isMomHappy) {
            const phone = {
                brand: 'Samsung',
                color: 'black'
            };
            resolve(phone);
        } else {
            const reason = new Error('mom is not happy');
            reject(reason);
        }

    }
);

// 2番目のPromise
async function showOff(phone) {
    return new Promise(
        (resolve, reject) => {
            var message = 'Hey friend, I have a new ' +
                phone.color + ' ' + phone.brand + ' phone';

            resolve(message);
        }
    );
};

// ES7のasync awaitスタイルでPromiseを呼び出す
async function askMom() {
    try {
        console.log('before asking Mom');

        let phone = await willIGetNewPhone;
        let message = await showOff(phone);

        console.log(message);
        console.log('after asking mom');
    }
    catch (error) {
        console.log(error.message);
    }
}

// ここでもasync awaitを使う
(async () => {
    await askMom();
})();

Promiseの使用とそのタイミング

なぜPromiseが必要なのでしょうか?Promiseが登場する前の世界はどのようなものだったのでしょうか?これらの質問に答える前に、基本から見直してみましょう。

通常の関数と非同期関数

これら二つの例を見てみましょう。どちらも二つの数値を加算します:一つは通常の関数を使って加算し、もう一つはリモートで加算します。

二つの数値を加算する通常の関数

// 通常通り二つの数値を加算

function add (num1, num2) {
    return num1 + num2;
}

const result = add(1, 2); // すぐにresult = 3が得られる
二つの数値を加算する非同期関数
// リモートで二つの数値を加算

// APIを呼び出して結果を得る
const result = getAddResultFromServer('http://www.example.com?num1=1&num2=2');
// result = "undefined"が得られる

通常の関数で数値を加算すると、すぐに結果が得られます。しかし、リモート呼び出しを行って結果を得る場合、待ち時間が発生し、すぐに結果を得ることはできません。

サーバーがダウンしていたり、応答が遅かったりするため、結果が得られるかどうかわかりません。結果を待つ間、プロセス全体がブロックされることを望みません。

APIの呼び出し、ファイルのダウンロード、ファイルの読み取りなど、いくつかの一般的な非同期操作を実行します。

非同期呼び出しにプロミスを使用する必要はありません。プロミスの前は、コールバックを使用していました。コールバックは、結果を得たときに呼び出す関数です。前の例を修正して、コールバックを受け入れるようにしましょう。

// リモートで2つの数値を加算
// APIを呼び出して結果を取得

function addAsync (num1, num2, callback) {
    // 有名なjQuery getJSONコールバックAPIを使用
    return $.getJSON('http://www.example.com', {
        num1: num1,
        num2: num2
    }, callback);
}

addAsync(1, 2, success => {
    // コールバック
    const result = success; // ここで結果 = 3 を得る
});

後続の非同期アクション

数値を1つずつ加算する代わりに、3回加算したいと思います。通常の関数では、次のようにします:

// 通常の方法で2つの数値を加算

let resultA, resultB, resultC;

 function add (num1, num2) {
    return num1 + num2;
}

resultA = add(1, 2); // すぐに結果A = 3 を得る
resultB = add(resultA, 3); // すぐに結果B = 6 を得る
resultC = add(resultB, 4); // すぐに結果C = 10 を得る

console.log('total' + resultC);
console.log(resultA, resultB, resultC);

これがコールバックを使った場合の見え方です:

// 2つの数値をリモートで加算する
// APIを呼び出して結果を取得する

let resultA, resultB, resultC;

function addAsync (num1, num2, callback) {
    // 有名なjQuery getJSONコールバックAPIを使用
	// https://api.jquery.com/jQuery.getJSON/
    return $.getJSON('http://www.example.com', {
        num1: num1,
        num2: num2
    }, callback);
}

addAsync(1, 2, success => {
    // コールバック1
    resultA = success; // ここでresult = 3を取得

    addAsync(resultA, 3, success => {
        // コールバック2
        resultB = success; // ここでresult = 6を取得

        addAsync(resultB, 4, success => {
            // コールバック3
            resultC = success; // ここでresult = 10を取得

            console.log('total' + resultC);
            console.log(resultA, resultB, resultC);
        });
    });
});

デモ: https://jsbin.com/barimo/edit?html,js,console

この構文は、深くネストされたコールバックのため、ユーザーフレンドリーではありません。

深くネストされたコールバックを避ける

プロミスは、深くネストされたコールバックを避けるのに役立ちます。同じ例をプロミスバージョンで見てみましょう:

// オブザーバブルを使ってリモートで2つの数値を加算する

let resultA, resultB, resultC;

function addAsync(num1, num2) {
    // ES6のfetch APIを使用し、Promiseを返す
	// .json()とは何か? https://developer.mozilla.org/ja/docs/Web/API/Body/json
    return fetch(`http://www.example.com?num1=${num1}&num2=${num2}`)
        .then(x => x.json()); 
}

addAsync(1, 2)
    .then(success => {
        resultA = success;
        return resultA;
    })
    .then(success => addAsync(success, 3))
    .then(success => {
        resultB = success;
        return resultB;
    })
    .then(success => addAsync(success, 4))
    .then(success => {
        resultC = success;
        return resultC;
    })
    .then(success => {
        console.log('total: ' + success)
        console.log(resultA, resultB, resultC)
    });

Promiseを使うことで、.thenでコールバックを平坦化できます。ある意味、コールバックのネストがないため、よりクリーンに見えます。ES7のasync構文を使うと、この例をさらに改善できます。

オブザーバブル

Promiseに落ち着く前に、非同期データを扱うのに役立つオブザーバブルという概念が登場しました。

同じデモをオブザーバブルで書いてみましょう。この例では、オブザーバブルにRxJSを使用します。

let Observable = Rx.Observable;
let resultA, resultB, resultC;

function addAsync(num1, num2) {
    // ES6のfetch APIを使用し、Promiseを返す
    const promise = fetch(`http://www.example.com?num1=${num1}&num2=${num2}`)
        .then(x => x.json());

    return Observable.fromPromise(promise);
}

addAsync(1,2)
  .do(x => resultA = x)
  .flatMap(x => addAsync(x, 3))
  .do(x => resultB = x)
  .flatMap(x => addAsync(x, 4))
  .do(x => resultC = x)
  .subscribe(x => {
    console.log('total: ' + x)
    console.log(resultA, resultB, resultC)
  });

オブザーバブルはもっと興味深いことができます。例えば、delay関数を3秒遅らせたり、特定の回数だけ再試行できるようにすることができます。

...

addAsync(1,2)
  .delay(3000) // 3秒遅延させる
  .do(x => resultA = x)
  ...

RxJsに関する私の投稿の一つをこちらで読むことができます。

結論

コールバックとプロミスに慣れ親しむことは重要です。それらを理解し、使いこなしてください。オブザーバブルについてはまだ心配しないでください。状況に応じて、これら三つすべてがあなたの開発に関与する可能性があります。

Source:
https://www.digitalocean.com/community/tutorials/understanding-javascript-promises