Promiseによる非同期処理

2024/02/03更新

目次

概要

従来よりJavaScriptのコードの複雑化の一因であった非同期処理について、ES2015からPromiseオブジェクトが登場し、より簡単に分かりやすく記述できるようになった。さらに、ES2017からは、async/await構文が導入され、Promiseによる記述をさらに簡略化できるようになった。

Promiseオブジェクト

Promiseの基本

Promiseは、従来コールバック関数を多用するなどして複雑化していた非同期処理の記述を簡単にするためのオブジェクトである。

Promiseオブジェクトは、通常非同期関数によって返される。Promiseオブジェクトは、待機(pending)・履行(fulfilled)・拒否(rejected)の3通りの状態を持っており、非同期処理が完了すると待機から履行または拒否へ状態が変化するしくみとなっている。履行または拒否のいずれかになった状態(待機ではない状態)を完了(settled)といい、一度完了したら待機へ戻ることはない。履行・拒否・完了は、日本語ではそれぞれ「成功」「失敗」「決定」と言った方が分かりやすいかもしれない。

このPromiseオブジェクトに後述のthen()などを使って関数をあらかじめ登録しておけば、Promiseの状態が変化した時点でその関数が実行されるようになる。

// Promiseを返す非同期関数の作成
const myAsyncFunc = function(url) {
    // returnするのはPromiseオブジェクト。
    return new Promise(function(resolve, reject) {
        // ここに目的の非同期処理を記述する。
        const img = new Image();
        img.src = url;
        // 非同期処理が成功したときは、resolve()を呼んでPromiseを「履行」する。
        // resolve()の引数として渡したものがthen()に渡る。
        img.onload = function() {
            resolve(`${url} success`);
        };
        // 非同期処理が失敗したときは、reject()を呼んでPromiseを「拒否」する。
        // reject()の引数として渡したものはthen()やcatch()などに渡る。
        img.onerror = function() {
            reject(`${url} failed`);
        };
    });
};

上記のようにして定義した非同期関数は、次のようにして使うことができる。

// チェーン(連鎖)表記
myAsyncFunc(~).then(thenFunc1).then(thenFunc2);

// 分かりやすく分解して書いた場合
const promise1 = myAsyncFunc(~);
const thenPromise1 = promise1.then(thenFunc1);
const thenPromise2 = thenPromise1.then(thenFunc2);
  • myAsyncFuncは、Promiseオブジェクト(promise1)を返し、非同期処理を実行する。

  • promise1が履行されると、then()であらかじめ設定したthenFunc1が実行される。thenFunc1の引数には、promise1が内部でresolve()に渡したオブジェクトや値が渡される。

  • then()はPromiseを返すため、promise1.then(~)もまたPromiseオブジェクト(thenPromise1)である。

  • thenPromise1は、thenFunc1がPromiseを返したときは、そのPromiseの履行(または拒否)をもって履行(または拒否)される。なお、Promise以外のオブジェクトや値が返されたときは、そのオブジェクトや値をもって即座に履行される。

  • thenPromise1が履行されると、さらにthenFunc2が実行される。thenFunc2の引数には、thenPromise1が履行時に渡したオブジェクトや値が渡される。

  • このようにして、then()の連鎖による非同期関数の直列実行が簡単にできる。

具体的なコードで見てみると、以下のようになる。

// myAsyncFunc('aaa.jpg')を実行
const promise1 = myAsyncFunc('aaa.jpg');

// promise1が履行したときの処理
const thenPromise1 = promise1.then(function(msg) {
    // myAsyncFunc('aaa.jpg')が完了(履行)したときにここが実行される。
    console.log(msg); // aaa.jpg success
    // myAsyncFunc('bbb.jpg')を実行
    const promise2 = myAsyncFunc('bbb.jpg');
    // thenの中でPromiseを返したので、promise2の結果がthenPromise1の結果となる。
    return promise2;
});

// thenPromise1が履行したときの処理
const thenPromise2 = thenPromise1.then(function(msg) {
    // myAsyncFunc('bbb.jpg')が完了(履行)したときにここが実行される。
    console.log(msg); // bbb.jpg success
    // thenの中でPromiseを返さないと、thenPromise2は即座に履行される。
    return 'Done.';
});

then()の中で、再びpromise.then(~)のチェーンをネストして記述するのは、あまり良くないとされており、実際意図しないコードになる可能性もあるため、注意が必要である。then()の中で再び非同期処理が行なわれてPromiseオブジェクトが登場した場合(上記例のpromise2)は、すぐにそれをreturnして、次のthen()に回した方が分かりやすくなる。

Promiseの完了と解決の違い

Promiseの説明の中では、ここまでに出てきた履行や完了の他に「解決(resolved)」という用語が出てくることがある。解決は、Promiseコンストラクタに渡した関数が、引数として渡される解決用関数を呼び出した状態のことを指す。一般には、「解決」によって履行または拒否が定まるので、それはつまり「完了」と同じ状態であり、さらに文脈的に明らかな場合は特に「履行」状態になったことを指すことが多い。

しかし、解決用関数に別のPromiseが渡された場合、元のPromiseが履行と拒否のどちらになるかは、渡されたPromise次第となり、「解決」はされても「完了」はしていない状態になる。then()は「解決」ではなく「完了」のタイミングで実行されるので、解決用関数の呼び出しと同時に必ずthen()が呼ばれるとは限らない。

以下は、「解決」と「完了(履行)」が時間差で訪れるコードの例である。

const firstPromise = new Promise(function(resolveFirst) {
    const anotherPromise = new Promise(function(resolveAnother) {
        // anotherPromiseは1秒後に「解決」して「完了」状態になる。
        setTimeout(resolveAnother, 1000);
    });
    resolveFirst(anotherPromise);
    console.log('Resolved.');
    // resolveFirst()が呼び出されたので、この時点でfirstPromiseは「解決」済みとなる。
    // しかし、firstPromiseはまだ履行または拒否が定まっておらず「完了」状態ではない。
});
firstPromise.then(function() {
    // then()は、「解決」のタイミングではなく、「完了」のタイミングで呼ばれる。
    console.log('Settled.');
});
  • 参考:Promise() constructor - JavaScript | MDN
    「Return value」の節に「The promise object will become "resolved" when either of the functions resolveFunc or rejectFunc are invoked.」「Note that if you call resolveFunc or rejectFunc and pass another Promise object as an argument, it can be said to be "resolved", but still not "settled".」との説明がある。

Promiseによる並列実行

then()では直列実行が記述できたが、並列実行をしたい場合はPromise.all()を使う。Promise.all()自体もまたPromiseである。Promise.all()にPromiseの配列を渡すと、渡した全てのPromiseが履行したときに履行となり、then()には各Promiseが履行したオブジェクトや値の配列が渡される。

// myAsyncFunc1、myAsyncFunc2、myAsyncFunc3は全てPromiseを返す非同期関数
const procs = [myAsyncFunc1(), myAsyncFunc2(), myAsyncFunc3()];
// Promise.all()にPromiseの配列を渡すと…
Promise.all(procs).then(function(arr) {
   // …全てのPromiseが履行たときにthenの中身が実行される。
   console.log(arr[0]); // myAsyncFunc1が履行した値
   console.log(arr[1]); // myAsyncFunc2が履行した値
   console.log(arr[2]); // myAsyncFunc3が履行した値
});

Promise.all()と似たものにPromise.race()がある。こちらは複数のPromiseの中でいずれか一つが最初に完了(履行または拒否)したときに完了(履行または拒否)するPromiseを返す。Promise.all()はAND条件、Promise.race()はOR条件で非同期処理を並列実行するものと考えることができる。

最初から解決済みのPromiseとしてPromise.resolve(obj)というものが使える。定数値をPromiseオブジェクトでラップしたい場合などに使える。なお、拒否済みのPromiseはPromise.reject(obj)である。Promise.all()Promise.race()に渡す配列にPromise以外が含まれていた場合は、自動的に履行済みのPromiseとしてラップされる。

Promiseのエラー処理

then()の引数としてもう一つ関数を渡すと、Promiseが拒否されたときに実行されるエラー処理を指定できる。

Promise.reject('Not found').then(
    // 履行時に実行する処理
    function(obj) { console.log('OK'); },
    // 拒否時に実行する処理
    function(err) { console.log(`Error: ${err}`); }
);
// Error: Not found

エラー処理はcatch()を使って指定することもできる。catch(func)は、then(null, func)と同等である。Promiseの拒否は、then()catch()で処理されない限り、伝搬していく。以下の例では、2番目の処理で拒否されているため、次のthen()で指定された3番目の処理は行われず、catch()で指定されたエラー処理までスキップされている。ただし、catch()内で再び履行されているため、最後の4番目の処理は実行されている。

// 1番目の処理
Promise.resolve(1).then(function(obj) {
    // 2番目の処理
    console.log(`OK: ${obj}`);
    return Promise.reject(2); // 拒否!
}).then(function(obj) {
    // 3番目の処理
    console.log(`OK: ${obj}`);
    return Promise.resolve(3);
}).catch(function(err) {
    // エラー処理
    console.log(`Error: ${err}`);
    return Promise.resolve(4);
}).then(function(obj) {
    // 4番目の処理
    console.log(`OK: ${obj}`);
});
// OK: 1
// Error: 2
// OK: 4

Promiseでは、例外も適切にエラー処理を行なうことができる。例えば、new Promise(~)内で、例外が発生したりthrow objされたりすると、そのPromiseをreject(obj)で拒否するのと同じことになる。また、then(~)内で例外が発生したりthrow objされたりした場合も、そのthen()が拒否という扱いになって、後続のcatch()などがハンドリングすることができる。逆に、promise.then(~)などを従来のtry-catch文で囲っても、このような例外をハンドリングすることはできないので注意が必要である。

//try {
    myAsyncFunc().then(function(obj) {
        // then内で発生する例外
        throw 'Promise error!';
    }).catch(function(err) {
        // Promiseの正しい例外処理
        console.log(err); // Promise error!
    });
//} catch (e) {
//    // then内で発生する例外に対して、従来のtry-catch文でこのように外側を囲っても無意味である。
//    console.log(e);
//}

async/await

async宣言とawait実行

asyncawaitは、Promiseによる非同期関数の記述をさらに簡潔にできる構文である。

基本的なルールは以下の通りである。Promiseを理解していれば、async/awaitを使うことはたやすい。

  • Promiseを返す非同期関数を実行するときに、awaitキーワードを付けると、その非同期関数が終わるまで、その行で処理が一時停止するようになり、コード上はあたかも同期関数のように記述できるようになる。

  • awaitをつけて実行した非同期関数の戻り値はPromiseオブジェクトではなく、その関数がresolveした値となる。また、その関数がrejectした場合は、例外が投げられるため、従来のtry-catch文が使える。

  • await実行を含む関数は、必ずasyncキーワードを付けて宣言しなければならない。

  • asyncキーワードを付けた関数は必ずPromiseを返すように自動的にラップされる。関数内でreturnするとPromiseresolveしたことになり、関数内でthrowするとPromiserejectしたことになる。

// Promiseを返す非同期関数
const myAsyncFunc = function(x) {
    return new Promise(function(resolve, reject) {
        if (x) {
            resolve('hoge');
        } else {
            reject('fuga');
        }
    });
};

// 従来のthen/catchによる書き方
const myFunc = function(x) {
    const promise = myAsyncFunc(x);
    promise.then(function(msg) {
        console.log(msg);
    }).catch(function(msg) {
        console.log(msg);
    });
};
myFunc(true);  // hoge
myFunc(false); // fuga

// async/awaitによる書き方
const myFunc = async function(x) {
    let msg;
    try {
        msg = await myAsyncFunc(x);
    } catch (e) {
        msg = e;
    }
    console.log(msg);
};
myFunc(true);  // hoge
myFunc(false); // fuga

別の非同期関数の結果を返す関数を作りたい場合、awaitせずにPromiseを返すか、awaitしてから値を返すかで迷うことがある。

// どちらがいいのか?
// (1) awaitして値を返しPromiseでラップする。
const myFunc = async function() {
    return await anotherAsyncFunc();
}
// (2) awaitせずにPromiseをそのまま返す。
const myFunc = function() {
    return anotherAsyncFunc();
}

上記の場合、anotherAsyncFuncPromiseを返す非同期関数とすると、myFuncはいずれもPromiseを返すことになるため、同じような挙動になると思われる。

ただ、anotherAsyncFunc()rejectされた場合の動作が大きく異なる。(1)の場合は、returnする前に例外が発生してしまう。つまり、ここはtry-catch文で囲んで、anotherAsyncFunc()の結果に対してmyFuncが責任を持つ必要がある。一方、anotherAsyncFunc()がどんな結果になろうとも、それをそのままmyFuncの結果としたい場合は、(2)のようにPromiseをそのまま返せばよい。

外部リンク