Promiseに関して
どうもフロントエンドエンジニアのoreoです。今回は、Promiseについて再整理します。
1 同期処理と非同期処理
ブラウザにおいて、JavaScriptは主にメインスレッド上で実行されます。処理はコールスタックに積まれ、後入れ先だし(LIFO)で、実行されています。同期処理では、一つの処理が完了するまでは、次の処理に移行しません。
一方、非同期処理は、処理がコールスタックから一時的に切り離され、コールバックキュー(※)に処理が格納されます。コールスタックに処理が積まれている状態では、コールバックキューに格納された処理は待ちの状態になります。コールスタックの処理が空になれば、コールバックキューに格納されている処理が、イベントループによって、先入れ先出し(FIFO)でコールスタックに積まれ処理されます。
※コールバックキューについて
コールバックキューにはマクロタスク、マイクロタスクの2種類のキューがあります。
- マクロタスク
- タスクキューとも呼ばれる。
-
setTimeoutなど。 - イベントループで順番が来ると、ひとつずつタスクを実行する。
- マイクロタスク
- ジョブキューとも呼ばれる。
-
Promiseなど。 - イベントループで順番が来ると、全てのジョブを実行する
コールバックキュー、コールスタック、イベントループ等の詳細は下記ご参照ください。
2 コールバック地獄について
2-1 コールバック関数を用いて非同期処理の順番を制御する
同期処理のみの場合は、ex1のようにグローバルコンテキストにそって処理が実行され、メッセージが①→②→③の順で出力されます。
【ex1】
/**
* 以下の順番で出力
* ①りんごは何個?
* ②りんごは1個
* ③わかりました!
*/
function question(){
console.log("①りんごは何個?");
}
function apple(num) {
console.log(`②りんごは${num}個`);
}
function ok(){
console.log("③わかりました!");
}
question()
apple(1);
ok()
しかし、ex2のように、非同期処理のsetTimeoutを用いると、第二引数に渡す遅延時間を0としても、メッセージの出力順が①→③→②と、ex1と比べて②と③の順番が入れ替わります。
これは、setTimeoutの第一引数の処理がマクロタスクに積まれ、コールスタックの処理(ここでのquestion()とok())が終了した後に、呼ばれるためです。
【ex2】
/**
* 以下の順番で出力
* ①りんごは何個?
* ③わかりました! ※②と③の出力が入れ替わった!
* ②りんごは1個
*/
function question(){
console.log("①りんごは何個?");
}
function apple(num) {
setTimeout(function () { //非同期処理のsetTimeoutを使う
console.log(`②りんごは${num}個`);
}, 0); //setTimeoutの第二引数には遅延時間を0として渡す
}
function ok(){
console.log("③わかりました!");
}
question()
apple(1);
ok()
ここで、コールバック関数を使用すると、非同期処理setTimeoutを使用しても、ex1のような出力が可能です。具体的には、ex3のように、apple関数の第一引数にコールバック関数を設定し、コールバック関数にok関数を渡して実行すると、ex1と同じくメッセージが①→②→③の順番で出力されます。
このように、コールバック関数を用いると、非同期処理が絡んだ実装において、処理順を制御すること可能です。
【ex3】
/**
* 以下の順番で出力
* ①りんごは何個?
* ②りんごは1個
* ③わかりました!
*/
function question() {
console.log("りんごは何個?");
}
function apple(callback, num) { //appleの第一引数にコールバック関数を設定
setTimeout(function () {
console.log(`りんごは${num}個`);
callback(); //appleの第一引数に渡したコールバック関数をここで実行
}, 0);
}
function ok() {
console.log("わかりました!");
}
question();
apple(ok, 1); //okをappleの第一引数に渡して実行
2-2 コールバック関数を用いて、複数の非同期処理の順番を制御する
次に、コールバック関数を用いて、複数の非同期処理を順番に実行する場合を考えます。
ex4のように、apple関数を宣言したとします。apple関数では、第一引数にコールバック関数、第二引数にりんごの個数numをとります。apple(function (num) {},1); を実行すると、1秒後に「りんごは1個」と出力されます
【ex4】
/**
* 以下が出力
* りんごは1個
*/
function apple(callback, num) {
setTimeout(function () {
console.log(`りんごは${num}個`);
num += 1;
callback(num);
}, 1000);
}
apple(function (num) {}, 1);
「りんごは2個」までと出力させたい場合は、ex5のようにapple関数の第一引数に、apple(function (num) {},num); を与えると、1秒後に「りんごは1個」2秒後に「りんごは2個」と出力されます。この程度なら読み解くことが可能です。
【ex5】
/**
* 1秒毎に以下の順番で出力
* りんごは1個
* りんごは2個
*/
//「りんごは2個」と出力させる
apple(function (num) {
apple(function (num) {}, num);
}, 1);
しかし、「りんごは10個」まで出力させたい場合は、ex6のように冗長なコードとなり、パッと見で読み解くことが難しくなります
このようにコールバック関数を用いて非同期処理のチェーンを実装した場合、ネストが深くなり可読性が下がります。この現象がコールバック地獄と呼ばれるものです。
【ex6】
/**
* 1秒毎に以下の順番で出力。「りんごは10個」まで出力される
* りんごは1個
* ・
* ・
* りんごは10個
*/
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {
apple(function (num) {}, num);
}, num);
}, num);
}, num);
}, num);
}, num);
}, num);
}, num);
}, num);
}, 1);
3 Promiseオブジェクトで非同期処理を制御する
Promiseを使用して非同期処理を実装すると、コールバック関数よりも、簡単でわかりやすく記載することができます。
3-1 Promiseの基本構文
new Promise(function(resolve,reject){})のように、二つの引数resolve、rejectを持ったコールバック関数function(resolve,reject){}をnew Promise()に渡して、Promiseオブジェクトをインスタンス化します。
Promiseオブジェクトでは、主に.thenメソッド、.catchメソッド、.finallyメソッドを使います(ex7~ex10)。
3-1-1 .thenメソッド
ex7のようにnew Promiseのコールバック関数の中で、resolveが実行されると、.thenメソッドに渡したコールバック関数が実行されます。ここで.thenメソッドに渡したコールバック関数では、resolveに渡した引数(ここでは「resolveです!」)を取得することができます。
【ex7】
/**
* 以下が出力
* resolveです!
* 処理は終了です!
*/
new Promise(function (resolve, reject) {
resolve("resolveです!");
})
.then(function (data) {
console.log(data);
})
.catch()
.finally(function () {
console.log("処理は終了です!");
});
.thenメソッドのチェーンを実装したい場合、一つ目の.thenメソッド内で二つ目の.thenメソッドに渡したいデータをreturnすると、二つ目の.thenメソッドでそれを受け取ることができます(ex8)。
【ex8】
/**
* この場合以下が出力される。
* resolveです!
* resolveです!
* 処理は終了です!
*/
new Promise(function (resolve, reject) {
resolve("resolveです!");
})
.then(function (data) {
console.log(data);
return data //ここでデータをreturnする。
})
.then(function (data) {
console.log(data); //データを取得できる
})
.catch()
.finally(function () {
console.log("処理は終了です!");
});
3-1-2 .catchメソッド
また、ex9のようにnew Promise()のコールバック関数の中で、rejectが実行されると、.catchメソッドに渡したコールバック関数が実行されます。.catchメソッドに渡したコールバック関数では、rejectに渡した引数(ここでは「rejectです!」)を取得することができます。rejectは、なんらかのエラーが発生したときに、それをPromiseに通知します。
【ex9】
/**
* 以下が出力
* rejectです!
* 処理は終了です!
*/
new Promise(function (resolve, reject) {
reject("rejectです!");
})
.then()
.catch(function (data) {
console.log(data);
})
.finally(function () {
console.log("処理は終了です!");
});
.thenメソッドの中で、エラーを検知し、.catchメソッドに処理を移行したい場合は、ex10のように.thenメソッドの中で、throw new Errorを実行します。そうすると.catchメソッドに処理を移行できます。
【ex10】
/**
* この場合以下が出力される。
* resolveです!
* throw new Errorでエラーを検知!
* 処理は終了です!
*/
new Promise(function (resolve, reject) {
resolve("resolveです!");
})
.then(function (data) {
console.log(data);
throw new Error();
})
.catch(function (val) {
console.log("throw new Errorでエラーを検知!");
})
.finally(function () {
console.log("処理は終了です!");
});
3-1-3 .finallyメソッド
.finallyメソッドのコールバック関数に記載した処理は、.thenメソッド、.catchメソッドのどちらが呼ばれたかに関わらず実施されるので、共通の終了処理を記載します。.finallyメソッドのコールバック関数には引数を渡すことができないので注意が必要です。
尚、new Promise()のコールバック関数は同期的に処理を行い、.then()メソッド、.catch()メソッド、.finally()メソッドは、非同期的に処理が行われます。
3-2 Promiseチェーン
それではex4~ex6でコールバック関数を用いて実装した非同期処理のチェーンをPromiseを用いて置き換えてみます。
ex11のように、apple関数内で、Promiseのインスタンスをreturnします。new Promiseに渡すコールバック関数内では、setTimeoutの処理を書き、setTimeout内で、resolveします。
【ex11】
function apple(num) {
return new Promise(function (resolve) {
setTimeout(function () {
console.log(`りんごは${num}個`);
num += 1;
resolve(num);
}, 1000);
});
}
ex11で宣言したapple関数に対して、ex12のように.thenメソッドを繋げ、.thenメソッド内でPromiseオブジェクトをreturnすると、非同期処理のチェーンを実装できます。
ex12の出力結果は、ex6と同じですが、Promiseを用いた方が、直感的でわかりやすくなったかと思います。
【ex12】
/**
* 1秒毎に以下の順番で出力。「りんごは10個」まで出力される
* りんごは1個
* ・
* ・
* りんごは10個
*/
apple(1)
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
.then(function (num) {
return apple(num);
})
また、ex13のように、無名関数を簡略化できるアロー関数を用いて、ex12を置き換えるとさらに簡略化できます。
【ex13】
/**
* アロー関数を使用するとよりわかりやすく「りんごは10個」まで出力できる
* 以下の実装は、ex12と同じ出力結果になる
*/
apple(1)
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
.then((num) => apple(num))
3-3 その他、Promiseで使うメソッド
3-3-1 .allメソッド
.allメソッドを使うと、非同期処置を並列で実装することが可能です。
ex14のように、.allメソッドの引数にPromiseインスタンスを含んだ反復可能オブジェクトを渡します(この場合は、配列を渡しています)。配列の中にあるapple(1)、apple(2)、apple(3)の処理が全て終了すると、.thenのメソッドが実行されます。因みに、allメソッドの引数に渡したPromiseインスタンスのどれか一つがrejectされた場合は、.catchの処理が実行されます。
尚、.thenのコールバック関数の引数には、.allメソッドの反復可能オブジェクト内に渡したPromiseインスタンスのresolveの実引数が配列として渡されます。
【ex14】
/**
* 以下のように出力される
* りんごは1個
* りんごは2個
* りんごは3個
* 渡ってきたvalは [1, 2, 3]
* 並列処理終わり
*/
function apple(num) {
return new Promise(function (resolve) {
setTimeout(function () {
console.log(`りんごは${num}個`);
resolve(num);
}, 1000 * num);
});
}
Promise.all([apple(1), apple(2), apple(3)])
.then((val) => {
console.log("渡ってきたvalは", val); //このvalは[1, 2, 3]になる
console.log("並列処理終わり");
});
3-3-2 .raceメソッド
.raceメソッドでは、.allメソッドと同様に、引数にPromiseインスタンスを含んだ反復可能オブジェクトを渡します。しかし、.allメソッドとは異なり、引数に渡したPromiseインスタンスのどれか一つの処理が終了すれば、次の.thenメソッドに処理が移行します。
ex15では、.raceメソッドに、apple(1)、apple(2)、apple(3)を含む配列を渡します。この場合、apple(1)の処理が一番早く終了するので、処理が終了した段階で、次の.thenメソッドに移行します。因みに、一番早く終了した処理がrejectされた場合は、.catchの処理が実行されます。
尚、.thenのコールバック関数の引数には、.raceメソッドの反復可能オブジェクト内に渡したPromiseインスタンスで一番早く処理が終わったresolveの実引数が渡されます。
【ex15】
/**
* 以下のように出力される
* りんごは1個
* 渡ってきたvalは1
* 並列処理終わり
* りんごは2個
* りんごは3個
*/
function apple(num) {
return new Promise(function (resolve) {
setTimeout(function () {
console.log(`りんごは${num}個`);
resolve(num);
}, 1000 * num);
});
}
Promise.race([apple(1), apple(2), apple(3)])
.then((val) => {
console.log("渡ってきたvalは", val);
console.log("並列処理終わり");
});
3-3-3 その他メソッド
この他に、.allSettledメソッド、anyメソッドがありますが、詳細は👇をご覧ください。
4 最後に
あまり馴染みが無かったコールバックキュー、イベントループなどに関して調べることができ有意義でした。次回は、Promiseをさらに直感的に記述できるasync、awaitや例外処理について記載します。
Discussion