はじめに
この記事は、javascriptの中でも高度(と自分が思っている)非同期処理について、自分の中で整理も兼ねてアウトプットしている記事です。
できるだけ図を用いて、噛み砕いて非同期処理の概略を理解することを優先に心がけて記載しています。
そのため、正確性に欠ける場合がございます。(そもそも筆者が勘違いしている場合もあり。)
ご理解の上、お読みいただけますようよろしく願いいたします。
この記事を読んでいただき、非同期処理のイメージを掴んでいただいた後、さらに深く正確な知識を学んでいただくための足掛かりとして利用していただけると幸いです。
この記事を作成するにあたり以下のページを参考にさせていただきました。
イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理
JavaScriptの非同期処理を理解する その1 〜コールバック編〜
JavaScriptの非同期処理を理解する その2 〜Promise編〜
JavaScript Visualizer 9000
目次
・ 同期処理と非同期処理の違い
同期処理と非同期処理の違いを解説します。
・ JavaScriptはシングルスレッド
JavaScriptのシングルスレッドとうい特徴と非同期処理の関係について解説します。
・ コールスタック、タスクキュー、イベントループ
非同期処理を構成するコールスタック、タスクキュー、イベントループについて解説します。
・ Promiseについて
JavaScriptの非同期処理のPromise方式について解説します。
1 : Promiseの使い方 / とりあえず細かい理解はさておき、まずは使い方を説明します。
2 : Promiseを利用した場合の【キュー】や【コールスタック】の積まれ方。
3 : 擬似的なclassを作成 / Promiseと同じ(ような)挙動をする擬似クラスを作成し内部構造を解説します。
・ async/awaitについて
await構文について解説します。
同期処理と非同期処理の違い
そもそも同期処理と非同期処理の【同期・非同期】とは何に対してでしょうか??
ざっくり言うと 【前の関数の終わり】と【次の関数の始まり】 と思っていただければイメージがつきやすいと思います。
例えば以下のようなコードがあるとします。
a();
b();
c();
同期処理の場合は以下のイメージで処理が行われて行きます。
aの処理開始 ➡︎ aの処理終了 ➡︎ bの処理開始 ➡︎ bの処理終了 ➡︎ cの処理開始 ➡︎ cの処理終了
次に関数bが非同期処理であった場合は、以下のイメージとなります。
なぜ、この様な非同期処理が必要なのでしょうか??
例えば、関数bが非常に時間を要する処理だった場合、関数cの開始が遅れて全体としての処理完了が遅れてしまいます。
そこで、効率よく処理を回すために 関数bが終わる前に関数cを開始 させて、全体としての処理完了を早めることができます。
コードで見てみます。
function a() {
console.log("a")
}
function b() {
console.log("b")
}
function c() {
console.log("c")
}
a();
b();
c();
当然、同期処理のため、出力結果は以下となります。
a
b
c
では、関数bを非同期処理にしてみます。
function a() {
console.log(a.name)
}
function b() {
setTimeout(() => {
console.log("b")
}, 100)
}
function c() {
console.log(c.name)
}
a();
b();
c();
出力結果は以下となります。
a
c
b
関数bの終了を待たずして、関数cが実行されている事がわかります。
setTimeout関数は【第2引数】の値をミリ秒として【非同期でカウント】します。
【第1引数】には、 カウント完了後に実行したい関数を渡します。
上記の例の場合、関数b開始後、【100ミリ秒経過後】に 【() => {console.log("b")} 】 を実行します。
また、【第1引数に渡す関数】のように、後ほど実行したい関数を【コールバック関数】と呼びます。
※ 実は『カウント完了後に実行したい関数を渡します。』の部分は厳密には正確ではありません。これは後ほど説明いたします。
擬似的に処理に時間がかかる(100ミリ秒)処理をsetTimeoutで実装しました。
実際に【時間がかかるため非同期で行う処理】は、"データベースからデータを取得する処理"、"ネットワークからデータを取得する処理"、"ファイルを読み込む処理" などがあります。
さて、ここで問題になってくる場合があります。
例えば、以下の様な同期のコードがあるとします。
let count = 1;
function a() {
count++
}
function b() {
count++
}
function c() {
console.log(`トータル:${count}`)
}
a();
b();
c();
当然、出力は【3】になります。
では【関数b】が、先ほどのように【非同期】だった場合はどうでしょう?
let count = 1;
function a() {
count++
}
function b() {
setTimeout(() => {
count++;
}, 100)
}
function c() {
console.log(`トータル:${count}`)
}
a();
b();
c();
出力結果は【2】になります。
【関数b】で【count++】される前に、関数cが開始してログ出力 してしまうためです。
この場合に 3 を出力させるためにはどうすればよいでしょう?
以下の様にコールバック関数の中でc()を実行すれば、求める値が出力できます。
let count = 1;
function a() {
count++
}
function b() {
setTimeout(() => {
count++;
+ c();
}, 100)
}
function c() {
console.log(`トータル:${count}`)
}
a();
b();
- c();
このように、非同期処理の難しさは非同期処理単体というよりも 【他の処理との実行順の制御】にポイントがあります。(と思います。)
では、単純に【基本は同期処理で書いて、時間のかかる処理は非同期処理】、【時間がかかる処理でも、実行順を担保したい場合は同期処理で書いてしまう】という方針で実装したらいいのでは?と思うかもしれません。
しかし、そういう訳にもいきません。
例えば、javascriptの【fetch関数】は、ざっくりいうと【サーバ上にデータを取得する】という処理を行ってくますが、これは処理に時間がかかるため、内部仕様的に非同期で処理されます。
以下の様なコードは求める値になりません。
const a = fetch("https://api.github.com/users/exampleuser"); //ネットワークから情報を取得
console.log(a);//取得結果をログに出力したい。
結果は以下のようになります。
Promise{<pending>}
この出力結果については後ほど解説していきます。とりあえず、fetchから求めるデータを取得する前に、console.logが実行されてしまい求める結果が出力できていないという事が言いたいのです。
このように、JavaScriptの組み込み関数にも非同期実行される関数も多数あります。よって、webアプリケーションを構築する上で 【非同期処理と他の処理との実行順の制御】 は避けては通れない訳です。
JavaScriptはシングルスレッド
JavaScriptはシングルスレッドで実行されると聞いた事があるかもしれません。
プログラムはシングルスレッド(単一のスレッド)で実行されます。シングルスレッドで実行されるとはつまり、ざっくりいうと 同時に複数の処理は行えない ということです。
ここで、先ほどの非同期処理のコードとイメージ図を見てみます。
function a() {
console.log("a")
}
function b() {
setTimeout(() => {
console.log("b")
}, 100)
}
function c() {
console.log("c")
}
a();
b();
c();
setTimeoutは【第2引数の値でミリ秒をカウント】すると説明しました。
ということは 関数bで【ミリ秒をカウントする処理】と【関数cの処理】が並行で実行 されており 【JavaScriptは同時に複数の処理は行えない】 と矛盾している気がします。
実は、この【カウント処理】はJavaScriptが実行しているわけではなく【他のところ】で実行されています。
この【他のところ】を説明しだすと深くなる(かつ、筆者の理解不足)ので割愛しますが、 とにかくJavaScript自体が実行している訳ではない という事が言いたいのです。
【他のところ】という言い方はいかがなものかと思うので、この記事では以後 【外部環境】 と呼びます。
つまり、時間がかかる処理をJavaScriptが 【外部環境に依頼】 して、先に関数cを処理してしまっているという訳です。
このような処理のおかげで 【JavaScirptはシングルスレッドであるが、非同期処理を行える】 という訳です。
コールスタック、タスクキュー、イベントループ
ここで、以下の場合について考えてみます。
setTimeoutの待機時間が0ミリ秒だった場合の実行順序は?
つまり以下の場合です。
function a() {
console.log("a")
}
function b() {
setTimeout(() => {
console.log("b")
}, 0)
}
function c() {
console.log("c")
}
a();
b();
c();
この場合の結果は以下のとなります。
a
c
b
これはなぜでしょうか?
待機時間が0秒であれば実質、上から順番に実行されることになり a ➡︎ b ➡︎ cとなるのでは?
これを説明するには、まず 【コールスタック】 を説明する必要があります。
コールスタックは、ざっくり言うと 【実行する関数を積んでいく箱】 とイメージしていただけると分かりやすいです。
プログラムが上から読まれていき、順番にコールスタックに積まれます。関数の実行処理がが完了したらコールスタックから取り出されます。
この処理を【JavaScript Visualizer 9000】というサイトを使って可視化してみます。
上から順に
1: 【関数a】が【コールスタック】に積まれる。
2: 【関数a】の処理が完了する。
3: 【関数a】が【コールスタック】から追い出される。
4: 【関数b】が【コールスタック】にが積まれる。
5: 【関数b】の処理が完了する。
6: 【関数b】が【コールスタック】から追い出される。
7: 【関数c】が【コールスタック】に積まれる。
8: 【関数c】の処理が完了する。
9: 【関数c】が【コールスタック】から追い出される。
という処理がなされている事がわかります。
【Task Queue】【Microtask Queue】【Event Loop】については、後ほど説明しますので今は気にしないでください。
以下のURLから挙動を確認できます。
※【RUN】ボタンをクリックし右下の【STEP】を押すことにより上記の処理が確認出来ます。
ここで注目するポイントは、コールスタックから関数が追い出されるのは 【関数が実行されたタイミング】ではなく【関数の処理が完了したタイミング】 となります。
では以下のコードの場合を見てみましょう。
function a() {
console.log("a")
b();
}
function b() {
console.log("b");
c();
}
function c() {
console.log("c")
}
();
以下の様に処理されていきます。
1: 【関数a】が【コールスタック】に積まれる。
2: 【関数a】が実行され、【関数b】を呼び出し【関数b】が【コールスタック】に積まれる。
3: 【関数b】が実行され、【関数c】を呼び出し【関数c】が【コールスタック】に積まれる。
4: 【関数c】の処理が完了する。
5: 【関数c】が【コールスタック】から追い出される。
6: 【関数b】の処理が完了する。(関数bの最後の処理である関数cが完了して、【コールスタック】から追い出された為)
7: 【関数b】が【コールスタック】から追い出される。
8: 【関数a】の処理が完了する。
9: 【関数a】が【コールスタック】から追い出される。
以下のURLから挙動を確認できます。
※【RUN】ボタンをクリックし【STEP】を押すことにより順次コールスタックに関数が積まれていきます。
この様に、後から入ってきたものを先に処理する方法を 「後入れ先だし」LIFO (Last In First Out) といいます。
JavaScriptは基本的にはコールスタックを1つしか持ちません。
そのため同時に複数の関数を実行することはありません。
これが 【シングルスレッド】 と呼ばれる所以です。
では、先ほどのコードに戻ってみます。
function a() {
console.log("a")
}
function b() {
setTimeout(() => {
console.log("b")
}, 0)
}
function c() {
console.log("c")
}
a();
b();
c();
上のコードと下のコードは同じ処理ですが【JavaScript Visualizer 9000】で視覚化する際に分かりやすくするため便宜上、setTimeoutのコールバック関数を切り出しています。
function a() { console.log("a") }
function consoleB() { console.log("B") }
function b() { setTimeout(consoleB, 0) }
function c() { console.log("c") }
a();
b();
c();
さらに、関数GCでラッピングします。
function a() { console.log("a") }
function consoleB() { console.log("B") }
function b() { setTimeout(consoleB, 0) }
function c() { console.log("c") }
function GC(){
a();
b();
c();
}
GC();
コードを実行する上で関数GCでラッピングする必要はありませんが【JavaScript Visualizer 9000】がグローバルコンテキストを可視化しない仕様であり分かりやすくするため、あえて擬似的に行なっています。
まず、以下の流れとなります。
下行はコールスタック、タスクキュー、外部環境の処理後の状況です。
cs:はコールスタック。左から入れられ、左から順に処理される。(後入先出)
1: グローバルコンテキスト(擬似関数GC)が【コールスタック】に積まれる。
|cs:⇆ GC|
2: 【関数a】が【コールスタック】に積まれる。
|cs:⇆ a/GC |
3: 【関数a】が、実行され処理が完了する。
|cs:⇆ a/GC |
4: 【関数a】が【コールスタック】から追い出される。
|cs:⇆ GC |
setTimeoutは【〇〇ミリ秒後に、コールバック関数を返してね】と【外部環境】に依頼します。
カウントを0ミリ秒後に設定しているので、待機時間はすぐに解かれて(非同期処理完了して)外部環境は【関数consoleB】をJavaScriptに返します。
実はここで外部環境は【関数consoleB】を【コールスタック】には返しません。
どこに返すのかというと 【タスクキュー】 という別の箱に返します。
【consoleB】は【タスクキュー】で順番待ちをします。
【タスクキュー】にいる状態では処理は実行されません。
あくまで【コールスタック】で実行されるので【consoleB】は順番が来たら【タスクキューからコールスタック】に移動され実行される訳です。
では、【consoleB】は、いつ【コールスタック】に入れられるのか?
それは 【コールスタックが空になったタイミング】 です。
タスクキューに積まれたタスクは、コールスタックにタスクが積まれている状態 では入れられる事はありません。
コールスタックが空になったタイミング で、タスクキュー から コールスタック に移動され積まれるわけです。
このコールスタックとタスクキューの状況をループしながら監視しているのが イベントループ です。
タスクキューで待機しているタスクは、先に入ってきたものから順にコールスタックに移動されます。 この方式を「先入れ先だし」FIFO (First In First Out) といいます。
今、上記の画像のタイミング(【consoleB】がタスクキューに入れられたタイミング)では コールスタックにタスクが積まれている ので、まだ 【consoleB】は【タスクキュー】で待機しています 。
JV9000で処理を進めてみます。(分かりやすいように【START】といれていますが動画の開始を表しており、処理の開始ではありません。(処理自体は続きからです。))
動画が早すぎる場合は、以下リンクで上記のコードが確認できるので試してみてください。
続きは以下の流れになっている事がわかります。
tq:はタスクキュー。
右から入れられ、左から順に処理される。(先入先出)
※consoleBがタスクキューに積まれた状態から。
5: 【関数b】がコールスタックから追い出される。
|tq:← consoleB ←|
|cs:⇆ GC |
6: 【関数c】がコールスタックに積まれる。
|tq:← consoleB ←|
|cs:⇆ c/GC |
7: 【関数c】が、実行され処理が完了する。
|tq:← consoleB ←|
|cs:⇆ c/GC |
8: 【関数c】がコールスタックから追い出される。
|tq:← consoleB ←|
|cs:⇆ GC |
9: 【関数GC】が完了しコールスタックから追い出される。
|tq:← consoleB ←|
|cs:⇆ |
10: 【consoleB】が【タスクキュー】から【コールスタック】に移動される。
|tq:← ←|
|cs:⇆ consoleB |
11: 【consoleB】が、実行され処理が完了する。
|tq:← ←|
|cs:⇆ consoleB |
12: 【consoleB】がコールスタックから追い出される。
|tq:← ←|
|cs:⇆ |
9の処理で積まれているタスクがなくなり、グローバルコンテキストも追い出され、 コールスタックが空 になって、はじめて 【consoleB】は【コールスタック】に積まれました。
つまり、待機時間が【0ミリ秒】なのに a ➡︎c ➡︎ b と出力された理由を言葉で説明するならば、
『【setTimeout】の非同期カウント終了後に実施される予定のタスク(この場合はコールバック関数consoleB)】はカウント終了後に一旦【タスクキューで待機】して【コールスタックが空】になった段階で【コールスタックに移動され】実行されるから』 となります。
ここで、先ほどのsetTimeoutの説明を振り返ります。
以前以下のように説明しました。
※ 実は『カウント完了後に実行したい関数を渡します。』の部分は厳密には正確ではありません。これは後ほど説明いたします。
正確には『【第1引数】には、カウント完了後に "タスクキューへ送りたい関数" を渡します。』となります。
例えば以下のようなコードがあるとします。
function pend() { //5秒間ループするので、その間【コールスタック】を占有する関数
const startTime = new Date();
while (new Date() - startTime < 5000){}
}
function first() {
setTimeout(() => {
console.log("ok");
}
, 1000)
}
first();
pend();
まず【グローバルコンテキスト】が【コールスタック】に積まれます。
次に【関数first】が【コールスタック】に積まれて、実行され、【console.log("ok")】が【外部環境】に送られます。
【関数first】の処理が完了したのでコールスタックから追い出されます。
(setTimeoutは、外部環境に預けるのが仕事なので、"ok"のログ出力ではなく、 預けた時点で処理完了)
次に【関数pend】が【コールスタック】に積まれて実行されます。
【関数pend】は5秒間ループするので、その間【コールスタック】を占有している状態となります。
(これは同期なので処理完了までコールスタックから追い出されない)
よって【関数first内】のsetTimeoutのカウント時間1秒を経過しても、"ok"は出力されません。
(1秒後に【タスクキュー】に送られるが、 【コールスタック】は、まだ空でない(pendの処理が完了していない。)為 【コールスタック】には、まだ送られない。)
【関数pend】開始から5秒経過して【関数pend】 ➡︎ 【グローバルコンテキスト】 の順で【コールスタック】から追い出され【コールスタック】が空になったタイミングで【コールバック関数(() => {console.log("ok")})】が【タスクキュー】から【コールスタック】に送られ実行されます。
よって、実際の"ok"のログ出力はプログラム開始から(約)5秒後となります。
以下の場合はどの様な順番で出力されるでしょうか?
function consoleCIF() {
console.log("callback in first");
}
function consoleCIS() {
console.log("callback in second");
}
function first() {
console.log("first");
setTimeout(consoleCIF, 0)
}
function second() {
console.log("second");
setTimeout(consoleCIS, 0)
}
function GC() {
first();
second();
}
GC();
1: first
2: second
3: callback in first
4: callback in second
という出力順になります。
流れを以下で追っていきます。
oe:は外部環境
タスクキューに送られるタイミングはそれぞれのタスクによります。
積まれた順に影響はしません。
1: 【GC】がコールスタックに積まれる。
|oe: |
|tq:← ←|
|cs:⇆ GC |
2: 【関数first】がコールスタックに積まれ、実行される。(『first』をログ出力)
|oe: |
|tq:← ←|
|cs:⇆ first/GC |
3: 【関数consoleCIF】が外部環境に渡される。
|oe: consoleCIF |
|tq:← ←|
|cs:⇆ first/GC |
4: 待機が0ミリ秒なので、【consoleCIF】は、外部環境から即時タスクキュー送られる。
|oe: |
|tq:← consoleCIF ←|
|cs:⇆ first/GC |
5: 【関数first】がコールスタックにから追い出される。
|oe: |
|tq:← consoleCIF ←|
|cs:⇆ GC |
6: 【関数second】がコールスタックに積まれ、実行される。(『second』をログ出力)
|oe: |
|tq:← consoleCIF ←|
|cs:⇆ second/GC |
7: 【関数consoleCIS】がタスクキューに積まれる。(待機が0ミリ秒なので即時タスクキューへ)
|oe: |
|tq:← consoleCIF/consoleCIS ←|
|cs:⇆ second/GC |
8: 【関数second】がコールスタックにから追い出される。
|oe: |
|tq:← consoleCIF/consoleCIS ←|
|cs:⇆ GC |
9: 【GC】がコールスタックから追い出される。
|oe: |
|tq:← consoleCIF/consoleCIS ←|
|cs:⇆ |
10: コールスタックが空になったので、【関数consoleCIF】がタスクキューからコールスタックに移動される。
|oe: |
|tq:← consoleCIS ←|
|cs:⇆ consoleCIF |
11: 【関数consoleCIF】が実行される。(『callback in first』をログ出力)
|oe: |
|tq:← consoleCIS ←|
|cs:⇆ consoleCIF |
12: 【関数consoleCIF】がコールスタックから追い出される。
|oe: |
|tq:← consoleCIS ←|
|cs:⇆ |
13: コールスタックが空になったので、【関数consoleCIS】がタスクキューからコールスタックに移動される。
|oe: |
|tq:← ←|
|cs:⇆ consoleCIS |
14: 【関数consoleCIS】が実行される。(『callback in second』をログ出力)
|oe: |
|tq:← ←|
|cs:⇆ consoleCIS |
15: 【関数consoleCIS】がコールスタックにから追い出される。
|oe: |
|tq:← ←|
|cs:⇆ |
JV9000でも確認してみてください。
ここでポイントとなってくるのは、【タスクキューは、到着した順でコールスタックに移動される】ということです。
上記【7】の段階で 【consoleCIF】と【consoleCIS】と2つのタスクが【タスクキュー】で順番待ちをしていますが【10】の段階で、先に【タスクキュー】から【コールスタック】に移動されるのは、先に【タスクキュー】に入れられた【consoleCIF】となります。
「先入れ先だし(FIFO)」(First In First Out)
では、【関数first】のsetTimeoutの非同期でのカウント時間を【300ミリ秒】にするとどうなるでしょうか?
function consoleCIF() {
console.log("callback in first");
}
function consoleCIS() {
console.log("callback in second");
}
function first() {
console.log("first");
setTimeout(consoleCIF, 300) //非同期でのカウント時間を300ミリ秒に変更。
}
function second() {
console.log("second");
setTimeout(consoleCIS, 0)
}
function GC() {
first();
second();
}
GC();
【関数first】の【setTimeout】が【関数second】内の【setTimeout】より先に実行されるため【外部環境】へは【consoleCIS】より先に【consoleCIF】の方が送られます。
しかし【consoleCIF】は【非同期で300ミリ秒カウント後】に【タスクキュー】に送られるため【非同期で0ミリ秒カウント後(即時)】である【consoleCIS】の方が、先に【タスクキュー】に送られるます。
よって、【consoleCIS】の方が先に【コールスタック】に送られ実行(「先入先出」FIFO)されることになります。
よって出力結果は以下となります。
1: first
2: second
3: callback in second
4: callback in first
JV9000でも確認していただきたいのですが、ここで下記の注意点があります。
上記で説明したように【タスクキュー】へは、【consoleCIS】の方が先に送られるのですが、JV9000の表示上は待機時間によらず、すぐに【タスクキュー】に送られてしまう為【consoleCIF】の方が先に【タスクキュー】に入るような表示となります。(コールスタックへの移動される順序は正しく行われる。)
これはJSV9000の仕様であるため、注意して確認してください。
では、ここまでの復習として以下のコードのログ出力の順番がわかりますでしょうか?
console.log("a");
setTimeout(() => {
setTimeout(() => {
console.log("b");
}, 10);
setTimeout(() => {
console.log("c");
}, 0);
console.log("d");
}, 20);
console.log("e");
考える上で押さえるポイントをまとめます。
今後このポイントを 【イベントループの法則】 と、本記事では呼ばせていただきます。
① 関数(タスク)が呼ばれたらコールスタックの一番上に積まれる。
② コールスタックは上から順(後入先出)に関数(タスク)を処理していく。
③ 関数(タスク)の処理が完了したらコールスタックから追い出される。
④ 非同期処理実行後に実行予約されるコールバック関数は、外部環境に送られ非同期処理完了後に外部環境から一旦タスクキューに送られる。
⑤ コールスタックが空になったら、タスキューの先頭で順番待ちしているタスクがコールスタックに送られる。
⑥ タスクキューは先入先出でコールスタックに送られる。
スッテプごとに①から⑥に則りコードを見ていくと出力結果が分かります。
答えは、a ➡︎ e ➡︎ d ➡︎ c ➡︎ b となります。
もし、イメージしにくければJSV9000で実行してみてください。
(下記リンク先のコードはJSV9000で分かりやすくするために、GCでラップおよび関数を切り出しています。)
JV9000の表示上は待機時間によらず、すぐに【タスクキュー】に送られるように表示されます。
これはJSV9000特有の仕様であるため注意して確認してください。
※今回の場合、関数bと関数cのタスクキューに入る順番が逆になります。
流れは下記のようになります。
function a() { console.log("a") }
function b() { console.log("b") }
function c() { console.log("c") }
function d() { console.log("d") }
function e() { console.log("e") }
function outerSetTime() {
setTimeout(b, 10);
setTimeout(c, 0);
d();
}
function GC() {
a();
setTimeout(outerSetTime, 20);
e();
}
GC();
1: 【GC】がコールスタックに積まれる。①
|oe: |
|tq:← ←|
|cs:⇆ GC |
2: 【関数a】がコールスタックに積まれる。①
|oe: |
|tq:← ←|
|cs:⇆ a/GC |
3: 【関数a】が実行され、処理完了する。②
|oe: |
|tq:← ←|
|cs:⇆ a/GC |
4: 【関数a】がコールスタックから追い出される。③
|oe: |
|tq:← ←|
|cs:⇆ GC |
5: 【outSetTime】が【外部環境】に送られる。④
|oe: outSetTime |
|tq:← ←|
|cs:⇆ GC |
6: 【関数e】がコールスタックに積まれる。①
|oe: outSetTime |
|tq:← ←|
|cs:⇆ e/GC |
7: 【関数e】が実行され、処理完了する。②
|oe: outSetTime |
|tq:← ←|
|cs:⇆ e/GC |
8: 【関数e】が【コールスタック】から追い出される。③
|oe: outSetTime |
|tq:← ←|
|cs:⇆ GC |
9: 【GC】が【コールスタック】から追い出される。③
|oe: outSetTime |
|tq:← ←|
|cs:⇆ |
10: 5:から20ミリ秒経過...
|oe: outSetTime |
|tq:← ←|
|cs:⇆ |
11: 【関数outSetTime】がタスクキューに送られる。④
|oe: |
|tq:← outSetTime ←|
|cs:⇆ |
12: 【関数outSetTime】が【タスクキュー】から【コールスタック】に送られる。⑤⑥
|oe: |
|tq:← ←|
|cs:⇆ outSetTime |
13: 【関数b】が【外部環境】に送られる。④
|oe: b |
|tq:← ←|
|cs:⇆ outSetTime |
14: 【関数c】が【外部環境】に送られる。④
|oe: b/c |
|tq:← ←|
|cs:⇆ outSetTime |
14: 【関数c】が即時【タスクキュー】に送られる。④
|oe: b |
|tq:← c ←|
|cs:⇆ outSetTime |
15: 【関数d】がコールスタックに積まれる。①
|oe: b |
|tq:← ←|
|cs:⇆ d/outSetTime |
16: 【関数d】が実行され、処理完了する。②
|oe: b |
|tq:← ←|
|cs:⇆ d/outSetTime |
17: 【関数d】がコールスタックから追い出される。③
|oe: b |
|tq:← c ←|
|cs:⇆ outSetTime |
18: 【関数outSetTime】がコールスタックから追い出される。③
|oe: b |
|tq:← c ←|
|cs:⇆ |
19: 【関数c】が【タスクキュー】から【コールスタック】に移動される。⑤⑥
|oe: b |
|tq:← ←|
|cs:⇆ c |
20: 【関数c】が実行され、、処理完了する。②
|oe: b |
|tq:← ←|
|cs:⇆ c |
21: 【関数c】がコールスタックから追い出される。③
|oe: b |
|tq:← ←|
|cs:⇆ |
22: 13:から10ミリ秒経過...
|oe: b |
|tq:← ←|
|cs:⇆ |
23: 【関数b】が【外部環境】から【タスクキュー】に移動される。④
|oe: |
|tq:← b ←|
|cs:⇆ |
24: 【関数b】が【タスクキュー】から【コールスタック】に移動される。⑤
|oe: |
|tq:← ←|
|cs:⇆ b |
25: 【関数b】が実行され、、処理完了する。②
|oe: |
|tq:← ←|
|cs:⇆ b |
26: 【関数c】がコールスタックから追い出される。③
|oe: |
|tq:← ←|
|cs:⇆ |
Promiseについて
実は、今まで述べてきた非同期処理の方法(コールバック方式)よりも、よりモダンな方式が存在します。
このコールバック方式には問題点がありました。
そして、それを改善するために登場したのが Promise になります。
問題点を簡単に説明するために以下のようなコードを用意しました。
function log(cb, t) {
setTimeout(cb, t)
}
const callback_my = () => console.log("My");
const callback_name = () => console.log("name");
const callback_is = () => console.log("is");
const callback_taro = () => console.log("Taro");
log(callback_my, 3000);
log(callback_name, 2500);
log(callback_is, 2000);
log(callback_taro, 1500);
カウント時間が短い方が先にコールスタックに積まれるため、現状、ログ出力には『Taro is name My』の順番で出力されます。
これを非同期の順番を制御して、『My name is Taro』の順番で出力したいとします。
先ほどの説明の通り、コールバック関数に、次に実行したい関数をいれます。
function log(cb, t) {
setTimeout(cb, t);
}
const callback_my = () => console.log("My");
const callback_name = () => console.log("name");
const callback_is = () => console.log("is");
const callback_taro = () => console.log("Taro");
log(() => {
callback_my();
log(() => {
callback_name()
log(() => {
callback_is()
log(() => callback_taro() ,
1500);
}, 2000);
}, 2500);
}, 3000);
このような、シンプルな関数でさえ非常に見通しが悪くなってしましました。
このようにコールバックのネストがどんどん深くなって可読性が著しく落ちる現象を、俗に コールバックヘル(コールバック地獄) と言いコールバック方式での非同期の処理の問題点の一つでした。
他にも、【非同期処理を並列で実行して、どれか1つでも処理が終わったらコールバックを呼ぶ】場合や【非同期処理をを並列で実行して、両方の処理が終わったらコールバックを呼ぶ】場合などの処理がややこしくなます。
また、【例外処理】の処理が複雑になります。
これを改善するために Promise が登場しました。
以下Promiseの解説でJavaScriptのclass構文に触れます。
この記事を読む上でclassについて、そこまで深い知識を必要としませんが下記の項目についてあやふやない場合は先にclassについて学習しておくことをおすすめします。
- classのインスタンス作成方法
- コンストラクタ
- インスタンスメソッド
- インスタンス変数
- this
ここから先、Promiseクラスの内部構造について説明する場面がありますが、あくまで理解するためのイメージであり実際の正確な定義とは異なっているためご注意ください。
Promiseを解説するにあたり本記事は以下の順序で解説していきます。
1: Promiseの使い方
とりあえず細かい理解はさておき、まずは使い方を説明します。
2: Promiseを利用した場合の【キュー】や【コールスタック】の積まれ方。
Promiseを利用した場合は、先程の説明における【キュー】や【コールスタック】における処理が異なります。
その辺りを説明します。
3: 擬似的なclassを作成して内部的に理解する。
実際にPromiseと同じ(ような)挙動をするMyPromiseクラスを作成し、内部構造を理解します。
1: Promiseの使い方
Promiseを用いた非同期処理の関数は、【コールバック関数で連鎖】させるのではなく、【Promiseクラスのインスタンスで非同期を連鎖】させていきます。(以後Promiseインスタンスと表記します。)
// コールバック方式 【引数に次に実行したいコールバック関数登録する。】
func(()=>{
func(()=>{
func()
})
})
// Promise方式 【promiseインスタンスで繋いでく。】
proimse.promise.promise
})
一旦、理解のために非同期ではなく同期処理の関数を【コールバック方式】から【Promise方式】で変換してみます。
以下の様な【syncLog関数】を作ってみました。
1000ミリ秒ループした後に、引数で受け取った【コールバック関数】を実行するだけの関数です。
function pend() {
const startTime = new Date();
while (new Date() - startTime < 1000) { }
}
function syncLog(cb) {
pend();
cb();
}
以下の様に呼んでみます。
function pend() {
const startTime = new Date();
while (new Date() - startTime < 1000) { }
}
function syncLog(cb) {
pend();
cb();
}
function callback_my() { console.log("My") };
function callback_name() { console.log("name") };
function callback_is() { console.log("is") };
function callback_taro() { console.log("taro") };
syncLog(() => {
callback_my();
syncLog(() => {
callback_name();
syncLog(() => {
callback_is();
syncLog(() => {
callback_taro();
})
})
});
})
1秒間隔で以下の様に出力されます。
My
name
is
Taro
やっていることは、下記と同じです。
pend();
callback_my();
pend();
callback_name();
pend();
callback_is();
pend();
callback_taro();
では、この【コールバック方式】のコードを【Promise方式】を利用して変換してみましょう。
まず、promiseを利用するにはクラスからPromiseインスタンスを作成します。
const promise = new Promise();
単純にPromiseクラスからインスタンスを作成しました。
promiseイスタンスは、thenという名前のインスタンスメソッドを持っています。
よって、promiseインスタンスは以下の様にthenメソッドを実行できます。
const promise = new Promise();
promise.then();
【thenメソッド】は、 新たなPromiseインスタンス を返します。
ということは、以下の様な連鎖ができます。(実は以下のままではエラーになりますが、今は気にしないでください。)
const promise = new Promise();
const promise2 = promise.then(); //thenメソッドはPromiseインスタンスを返す。
const promise3 = promise2.then(); //promise2はProimseインスタンスなのでthenメソッドを持つ。
const promise4 = promise3.then();
const promise5 = promise4.then();
もちろん、わざわざ変数に入れ直さなくても以下ので連鎖もできます。
const promise = new Promise();
promise.then().then().then().then();
Promise方式はイメージとして、以下の様に非同期処理を連鎖させていきます。
const promise = new Promise(<最初の処理>);
promise
.then(<2番目の処理>)
.then(<3番目の処理>)
.then(<4番目の処理>)
.then(<5番目の処理>);
この段階でも【コールバック方式】より見通しが良さそうな気がしてきますね。
Promiseクラスのコンストラクタに【最初の処理】を渡します。
この【最初の処理】を【Executor】と呼びます。
【thenメソッド】に【次の処理】を渡します。
ここでポイントとなるのは、コンストラクタに渡される【Executor関数】は、Promiseが new されたタイミングで【同期】で【コールスタック】に積まれて実行されます。(タスクキューに積んだり、外部環境に投げたりなど、ややこしいことはありません。)
例えば以下のようなコードがあったとします。
console.log("a")
new Promise(() => {
console.log("b");
})
console.log("c");
なんとなく深読みして a ➡︎ c ➡︎ b の順番で出力されそうな気がしませんか?(気がしない人は以下の説明を飛ばしていただいも大丈夫です。)
【Executor関数】自身、および関数内は、もちろん普通の関数と同様に【イベントループの法則】に則って処理されます。
なので、シンプルに以下の流れで実行されていきます。
1: console.log("a") が実行され、処理が完了する。
2: Promiseクラスからインスタンスが作成される。
3: コンストラクタが実行される。
4: コンストラクタは、引数で受け取った【Executor】を実行する。
5: console.log("b")が実行される。
6: 【Executor】の処理が完了する。(関数内部の処理が全て完了したので)
7: console.log("c")が実行され、処理が完了する。
Promiseクラスのコンストラクターを以下の様にイメージしてもらうと、当たり前ですよね。
class PromiseImage{
constructor(executor) {
executor(); // ③ シンプルに受けとった関数(executor)を同期的に実行
// つまり[ ()=>{ console.log("b") } ]を実行
}
}
console.log("a"); // ① a を出力
new PromiseImage(() => { // ② new されたのでconstructorを実行
console.log("b");
})
console.log("c"); // ④ c を出力
もちろん、Executor関数内に非同期処理があった場合も【イベントループの法則】に則って実行されます。
console.log("a")
new Promise(() => {
setTimeout(() => {
console.log("b");
}, 1000)
})
console.log("c");
a ➡︎ c ➡︎ (1ミリ秒カウント) ➡︎ b の順番でログ出力されます。
くどいですが、これもPromiseクラスの内部をイメージすると理解しやすいです。
class PromiseImage{
constructor(executor) {
executor(); // ③ 受けとったexecutorを同期的に実行する。
// ④ executor自体は非同期関数が渡されているので【イベントループの法則】に則って処理される。
// ⑤ setTimeoutが実行されて[console.log("b")]が外部環境に投げられる。
}
}
console.log("a"); // ① a を出力
new PromiseImage(() => { // ② newされたのでconstructorを実行
setTimeout(() => {
console.log("b"); // ⑦ カウント完了後、【外部環境】 => 【タスクキュー】 => 【コールスタック】 に移動されて実行
}, 1000)
})
console.log("c"); // ⑥ c を出力
もっと砕くと下記の流れと同じです。
function executor() {
setTimeout(() => {
console.log("b");
}, 1000)
}
console.log("a")
executor();
console.log("c");
このコードは既に説明したので分かると思います。(もしわからなければ、もう一度記事を見返すか、より分かりやすい別の記事で調べてみてください。)
では、実際にPromise方式を使っていきます。
一旦 "My" と "name"を出力したいと思います。
以下のような感じでしょうか。
const promise = new Promise(() => {
syncLog(callback_my);
})
promise.then(() => { syncLog(callback_name) })
実はこの書き方では【thenメソッド】に渡したコールバックが呼ばれません。
【thenメソッド】に渡したコールバック関数が実行されるのは【Executor関数内】で【resolve】という関数を実行したタイミングとなります。(実は語弊がある書き方ですが今は気にしないで下さい。)
よって、以下のように書き換える必要があります。
function pend() {
const startTime = new Date();
while (new Date() - startTime < 1000) { }
}
function syncLog(cb) {
pend();
cb();
}
const callback_my = () => console.log("My");
const callback_name = () => console.log("name");
const callback_is = () => console.log("is");
const callback_taro = () => console.log("Taro");
//-----------------ここからがPromiseに関係のある記述-----------------
- const promise = new Promise(() => {
+ const promise = new Promise((resolve) => { //resolveをExecutorの引数に渡す。
syncLog(callback_my);
+ resolve();//resolveが実行されるとthenメソッドの引数のコールバック関数が実行される。
})
promise.then(() => { syncLog(callback_name) })
これで、正しく 1000ミリ秒間隔で My ➡︎ name の順番で出力されるようになりました。
ここでも疑問があるかもしれません?
Executor関数の引数のresolveっていつどうやって渡しているの?
この辺りも後ほど説明しますので、今は『とりあえずこう書く』とだけ覚えてください。
では、次の"is"も出力してみましょう。
イメージとしては以下のようにthenで繋ぎます。
promise.then().then()
上記の様に繋げるには、一つ目のthenメソッドの返り値もPromiseインスタンスである必要があります。
promise.then() ➡︎ then()
//promise.then()の返り値がPromiseインスタンスなら次のthenメソッドが呼び出せる。
今、下記のコールバックは、syncLog(callback_name)を実行しているだけのように見受けられるので、これもpromiseインスタンスを返すように修正してみましょう。
promise.then(() => { syncLog(callback_name) });
// syncLog(callback_name)を実行しているだけ
const promise = new Promise((resolve) => { //resolveをExecutorの引数に渡す。
syncLog(callback_my);
resolve();//resolveが実行されるとthenメソッドの引数のコールバック関数が実行される。
})
- promise.then(() => { syncLog(callback_name) });
+ promise.then(() => {
+ return new Promise((resolve) => { //さらにpromiseインスタンスを作り直してreturnする。
+ syncLog(callback_name);
+ resolve(); // resolveが実行されて次のthenに渡されているコールバック関数[() => { syncLog(callback_is)]が
+ // 実行される。
+ })
+ }).then(() => { syncLog(callback_is) })
実行してみると、1秒間隔で "My" ➡︎ "name" ➡︎ "is" と出力されました。
ふと疑問に思いませんでしょうか?
【コールバック方式よりめんどくさくなっていないか?】
先ほどの説明を思い出してください。
【thenメソッド】は、 新たなPromiseインスタンス を返します。
つまり【thenメソッド】に渡される値が何であろうと【thenメソッド】は Promiseインスタンス を返します。
わざわざ上記のようにpromiseインスタンスを作り直す必要はないということです。
返ってくるpromiseインスタンスは、どのようなもの?と疑問が湧くかもしれませんが、今は『とりあえず、thenメソッドの返り値は必ずpromiseインスタンスになるので、特にpromiseインスタンスを作りなおさなくても、さらに【thenメソッド】が呼べる』。
そして、【thenメソッド】のコールバック関数が処理完了すると、さらに次の【thenメソッドのコールバック関数】が呼び出される事を、"そういうもの"だと覚えておいてください。
1つ目の【thenメソッド】に渡されたコールバック関数の返り値は何もありません。
promise.then(() => { syncLog(callback_name) })
// syncLog(callback_name)を実行しているだけで何も返り値はありません。
念の為、見てみるともちろん以下になります。
const test = () => { syncLog(callback_name) } //thenメソッドに渡されたコールバック関数。
const returnValue = test(); //実行結果の返り値を格納
console.log('returnValue:', returnValue);
//returnValue: undefined
【thenメソッド】に渡された引数の返り値がundefinedでも、promiseインスタンスが返されるので、以下のように繋げる事ができます。
promise
.then(() => { syncLog(callback_name) }); //ここの返り値はpromiseインスタンス
.then(() => { syncLog(callback_is) }); //もちろん、ここの返り値もpromiseインスタンス
では、先ほどの【コールバック方式】のコードを書き直してみます。
syncLog(() => {
callback_my();
syncLog(() => {
callback_name();
syncLog(() => {
callback_is();
syncLog(() => {
callback_taro();
})
})
});
})
const promise = new Promise((resolve) => { //resolveをExecutorの引数に渡す。
syncLog(callback_my);
resolve();//resolveが実行されるとthenメソッドの引数のコールバック関数が実行される。
})
promise
.then(() => { syncLog(callback_name) }) //コールバック終了後、次のthenのコールバックを実行する。
.then(() => { syncLog(callback_is) }) //コールバック終了後、次のthenのコールバックを実行する。
.then(() => { syncLog(callback_taro) })
見通しが良くなりましたね。
では、Executorに非同期関数を渡してみましょう。
function asyncPendLog(cb) {
setTimeout(() => {
cb();
}, 1000)
}
const promise = new Promise((resolve) => {
asyncPendLog(() => {
callback_my();
resolve();
});
})
promise.then(callback_name)
正しく "My" ➡︎ "name" の順で出力されました。
ここで、なぜ処理の順番を制御するかを思い出してください。
処理結果を次の処理で利用 したいからですよね。
現在は、処理の順番を制御しただけで次の処理に何も渡していません。
では処理結果を次の処理に渡す処理を見てみます。
Executor関数内のresolve実行時の引数に渡された値が、thenメソッドに渡されたコールバックに渡されます。
そして、次のthenに渡されたコーバック関数にはthenの中でreturnされた値が渡されます。
// 最終的に出力されるログ
argument of resolve and seconde then argument
※この場合は、1回目のthenコールバック内でconsole.log(vv)すればいいだけで、さらにthenで繋ぐ意味はないですが説明のためにreturnしました。
色々疑問点はあるかもしれませんが、とりあえず
executor内のresolveに渡された引数
⬇️
thenのコールバックの引数
⬇️
thenのコールバックでreturnされた値
⬇︎
次のthenのコールバックの引数
の流れで値が渡っていくことを覚えておいてください。
以前に以下のように注釈をしました。
【thenメソッド】に渡したコールバック関数が実行されるのは、【Executor関数内】で【resolve】という関数を実行したタイミングとなります。(実は語弊がある書き方ですが今は気にしないで下さい。)
実は、thenメソッドの引数に渡されたコールバックは、executorでresolveされたタイミングで【実行される】のではなく 【キュー】 に積まれます。
これを言い換えると、thenメソッドの引数に渡されたコールバックは、必ず非同期で実行されるということです。
const promise = new Promise((resolve) => {
console.log("executor");
resolve('then');
})
promise.then((v) => { console.log(v) });
console.log("last");
1: 【GC(グローバルコンテキスト)】がコールスタックに積まれる。
|oe: |
|tq:← ←|
|cs:⇆ GC |
2: 【executor】がコールスタックに積まれ、実行される。
|oe: |
|tq:← ←|
|cs:⇆ executor/GC |
3: resolveが実行される。
|oe: |
|tq:← ←|
|cs:⇆ executor/GC |
4: 【thenの引数のコールバック】が【キュー】に積まれる。
|oe: |
|tq:← 【thenの引数のコールバック】 ←|
|cs:⇆ executor/GC |
5: 【executor】が【コールスタック】から放り出される。
|oe: |
|tq:← 【thenの引数のコールバック】 ←|
|cs:⇆ GC |
6: 【console.log("last")】が実行される。
|oe: |
|tq:← 【thenの引数のコールバック】 ←|
|cs:⇆ GC |
7: 【GC(グローバルコンテキスト)】が【コールスタック】から放り出される。
|oe: |
|tq:← 【thenの引数のコールバック】 ←|
|cs:⇆ |
8: 【thenの引数のコールバック】が【コールスタック】に積まれる。
|oe: |
|tq:← ←|
|cs:⇆ 【thenの引数のコールバック】 |
9: 【thenの引数のコールバック】が実行されて、【コールバック】から追い出される。
|oe: |
|tq:← ←|
|cs:⇆ |
さて、以下のコードではresolveとcallback_myとcallback_nameはどのような順番で呼び出されるでしょうか?
function asyncPendLog(cb) {
setTimeout(() => {
cb();
}, 1000)
}
const promise = new Promise((resolve) => {
asyncPendLog( callback_my );
resolve();
})
promise.then(callback_name)
復習になるのですが
【 callback_my(asyncPendLogに渡されたコールバック関数)】は、【外部環境】 ➡︎ (1ミリ秒待機) ➡︎ 【タスクキュー】 ➡︎ 【コールスタック】 と移動して最終的に実行されます。
よって【resolve】は【callback_my(asyncPendLogの引数のコールバック関数)】より、先に実行されます。
【resolve自体】が【callback_my】より後に呼び出されることはありえません。
これは、カウント時間が0ミリ秒であってもです。
なぜなら【Executor関数】の処理が完了していない状態、つまり【resolve】が呼び出されるまでは【Executor関数】は【コールスタック】に積まれいるので【コールスタック】は当然空ではなく【タスクキュー】で待機している【callback_my】が【コールスタック】に移動されることはない為。
また、【callback_name】の方が【(callback_my)asyncPendLogに渡されたコールバック関数】より先にタスクキューに積まれるので先に実行されます。(タスクキューは先入先出)
...と言いたいところですが、ここは少し違和感がないでしょうか?
確かに【カウント時間が0ミリ秒】の場合、【resolve】が呼び出される前に【callback_my】が【コールスタック】に積まれることはないが、【resolve】が呼び出される時点で、既に【callback_my】が【外部環境】から【タスクキュー】には積まれているのは?
そうすると【resolve】が実行され【callback_name】が【タスクキュー】に積まれた時、既に【callback_my】が【タスクキュー】に積まれているので、【先入先出】により、【callback_my】の方が【callback_name】より先に実行されて "My" => "name" の順で出力されるのでは?と考えてしまうかもしれません。
以下のようになるのでは?という疑問
1: executorが実行される。
|oe: |
|tq:← ←|
|cs:⇆ asyncPendLog/GC |
2: 外部環境にcallback_myが渡される。
|oe: callback_my |
|tq:← ←|
|cs:⇆ asyncPendLog/GC |
3: asyncPendLogが放り出される。callback_myがすぐ(0カウント)タスクキューにつまれる。
|oe: |
|tq:← callback_my ←|
|cs:⇆ GC |
4: resoveがコールスタックにつまれる。
|oe: |
|tq:← callback_my ←|
|cs:⇆ resolve/GC |
5: resoveが実行されて、callback_nameがタスクキューにつまれる。
(この時点でcallback_myがタスクキューに既につまれている。)
|oe: |
|tq:← callback_my/callback_name ←|
|cs:⇆ GC |
よってcallback_my ➡︎ callback_name の順番に実行されるのでは?
しかし、実際はカウントが0ミリ秒であっても "name" ➡︎ "My" の順で出力されます。
これを説明するには 【マイクロタスクキュー】 の説明が必要になってきます。
2: Promiseを利用した場合の【キュー】や【コールスタック】の積まれ方
実はPromise方式のthenメソッドで逐次実行されるコールバックは、 【タスクキュー】ではなく【マイクロタスクキュー】 で、順番待ちをします。
【タスクキュー】と【マイクロタスクキュー】の共通点
- コールスタックが空になった場合 【先入先出】 でコールスタックに積まれる。
【タスクキュー】と【マイクロタスクキュー】の相違点
- 【マイクロタスクキュー】は【タスクキュー】に優先して【コールスタック】に積まれる。
つまり、コールスタックが空になった場合、まず【マイクロタスクキュー】で順番待ちをしているタスクが【コールスタック】に送られます。
【タスクキュー】の待ちタスクは【コールスタック】が空で、かつ【マイクロタスクキュー】に順番待ちをしているタスクがない場合に、初めて【コールスタック】に送られます。
以下のコードで見てみます。
function consoleA() {console.log("a")}
function consoleB() {console.log("b")}
function consoleC() {console.log("c")}
function consoleD() {console.log("d")}
function consoleE() {console.log("e")}
function GC() {
setTimeout(consoleE, 0);
new Promise((resolve) => { resolve() }).then(consoleA).then(consoleB);
new Promise((resolve) => { resolve() }).then(consoleC).then(consoleD);
}
GC();
流れを見ていきます。
【mtq】は【マイクロタスクキュー】です。
1: 【GC(グローバルコンテキスト)】がコールスタックに積まれる。
|oe: |
|tq:← ←|
|mtq:← ←|
|cs:⇆ GC |
2: 【consoleE】が外部環境に預けられ、即時タスクキューに積まれる
|oe: |
|tq:← consoleE ←|
|mtq:← ←|
|cs:⇆ GC |
3: 【executor(resolve1)】が【コールスタック】につまれる。
|oe: |
|tq:← consoleE ←|
|mtq:← ←|
|cs:⇆ resolve1/GC |
4: 【executor(resolve1)】がresolveされ【consoleA】が【マイクロタスクキュー】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA ←|
|cs:⇆ resolve1/GC |
5: 【executor(resolve1)】がコールスタックから追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA ←|
|cs:⇆ GC |
6: 【executor(resolve2)】が【コールスタック】につまれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA ←|
|cs:⇆ resolve2/GC |
7: 【executor(resolve2)】がresolveされ【consoleB】が【マイクロタスクキュー】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA/consoleB ←|
|cs:⇆ resolve2/GC |
8: 【executor(resolve2)】がコールスタックから追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA/consoleB ←|
|cs:⇆ GC |
9: 【GC】がコールスタックから追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleA/consoleB ←|
|cs:⇆ |
10: 【consoleA】がコールスタックに積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleB ←|
|cs:⇆ consoleA |
※【マイクロタスクキュー】に積まれたタスクの方が【タスクキュー】に積まれたタスクより、優先して【コールスタック】に積まれる。
11: 【consoleA】が実行され【コールスタック】から追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleB ←|
|cs:⇆ |
12: 【consoleC】が【マイクロタスクキュー】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleB/consoleC ←|
|cs:⇆ |
※thenメソッドのコールバック(consoleA)が処理完了したので、次のthenメソッドのコールバック(consoleC)が
【マイクロタスクキュー】に積まれます。
13: 【consoleB】が【コールスタック】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleC ←|
|cs:⇆ consoleB |
14: 【consoleB】が実行され【コールスタック】から追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleC ←|
|cs:⇆ |
15: 【consoleD】が【マイクロタスクキュー】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleC/consoleD ←|
|cs:⇆ |
16: 【consoleC】が【コールスタック】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleD ←|
|cs:⇆ consoleC |
17: 【consoleC】が実行され【コールスタック】から追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← consoleD ←|
|cs:⇆ |
18: 【consoleD】が【コールスタック】に積まれる。
|oe: |
|tq:← consoleE ←|
|mtq:← ←|
|cs:⇆ consoleD |
19: 【consoleD】が実行され【コールスタック】から追い出される。
|oe: |
|tq:← consoleE ←|
|mtq:← ←|
|cs:⇆ |
20: 【consoleE】が実行され【コールスタック】に積まれる。
|oe: |
|tq:← ←|
|mtq:← ←|
|cs:⇆ consoleE |
※ 【コールスタック】が空で【マイクロタスクキュー】で順番待ちのタスクがないので【タスクキュー】から【コールスタック】に移動させられます。
21: 【consoleE】が実行され【コールスタック】から追い出される。
|oe: |
|tq:← ←|
|mtq:← ←|
|cs:⇆ |
実際の動きも、JSV9000で確認してみてください。
rejectについて
以前、以下の様に説明しました。
コールバック方式は【例外処理】の処理がややこしくなる。
Promise方式での例外処理の方法を見てみます。
以下の様なコードがあります。
resolveが呼ばれたら、"正常"と出力されます。
new Promise((resolve) => {
resolve();
}).then(() => { console.log("正常") })
Executor内で例外を発生させてみます。
new Promise((resolve) => {
+ nothing(); //未定義の関数を呼び出そうとしている。
resolve();
}).then(() => { console.log("正常") })
resolveが呼ばれる前に、エラーが発生したため、"正常"は出力されません。
エラーが発生した場合に実行したい処理は、thenメソッドの第2引数に渡すと実行されます。
new Promise((resolve) => {
nothing();
resolve();
}).then(() => { console.log("正常") }, () => { console.log("異常") });
//第2引数にエラー時のコールバックを渡す。
// => 異常
また、引数には投げられたエラーオブジェクトが渡ってきます。
new Promise((resolve) => {
nothing();
resolve();
}).then(() => { console.log("正常") }, (e) => { console.log(e.message) })
// => nothing is not defined
例外を投げたい場合は、rejectという関数を呼び出します。
例えば、計算結果が50以下の場合は例外を投げたい場合は以下の様にします。
const calc = (v1, v2) => v1 + v2;
const logV = () => { console.log("正常:50以上です。") }
const logI = () => { console.log("異常:49以下です。") }
new Promise((resolve, reject) => {
if (calc(40, 20) >= 50) {
resolve()
} else {
reject()
}
}).then(logV, logI) // resloveが呼ばれたので、第1引数のコールバックを実行
// => 正常:50以上です。
new Promise((resolve, reject) => {
if (calc(10, 20) >= 50) {
resolve()
} else {
reject()
}
}).then(logV, logI) // rejectが呼ばれたので、第2引数のコールバックを実行
// => 異常:49以下です。
resolveと同様に、rejectに渡された値がコールバックに渡ります。
const calc = (v1, v2) => v1 + v2;
const logV = () => { console.log("正常:50以上です。") }
const logI = (r) => { console.log(`異常:49以下です。結果:${r}`) }//rejectに渡された引数rが渡ってくる。
new Promise((resolve, reject) => {
const r = calc(20, 20);
if (r >= 50) {
resolve()
} else {
reject(r) //計算結果をコールバックに渡す。
}
}).then(logV, logI)
//=> 異常:49以下です。結果:40
thenメソッド内で例外を発生させた場合も、同様に次のthenメソッドの第2引数のコールバックが実行されます。
const calc = (v1, v2) => v1 + v2;
const logV = () => { console.log("正常:50以上です。") }
const logI = () => { console.log("異常:49以下です。") }
new Promise((resolve) => {
resolve();
}).then(() => {
if (calc(10, 20) >= 50) {
return;
} else {
throw new Error(); //例外を投げる。
}
}).then(logV, logI); //例外が発生したので、第2引数が実行される。
また、thenメソッドの第2引数ではなく catchメソッドの 引数にコールバックを渡すことができます。
const logI = (r) => { console.log(`異常:49以下です。結果:${r}`) };
//rejectに渡された引数rが渡ってくる。
new Promise((_, reject) => {
reject(40); //説明に不要であるため簡素化しました。
}
- }).then(logV, logI);
+ }).then(logV).catch(logI) //thenメソッドの第2引数ではなくchathメソッドで実行。
Promiseチェーンで繋いでいる場合に、途中で例外が発生した場合 【最初に訪れるthenメソッドの第2引数のコールバック】 or 【chathメソッドの引数】 まで処理が飛ばされます。
const logI = (r) => { console.log(`異常:49以下です。結果:${r}`) }
new Promise((_, reject) => {
reject(40)
})
.then(logV) //logVは実行されない。
.then(logV) //logVは実行されなし。
.then(logV, logI) //ここの第2引数のコールバックまで飛ぶ。
例外が発生した場所、以前のchathは実行されません。(thenの第2引数も同様)
new Promise((resolve) => {
// reject(40);
resolve();
})
.catch(() => { console.log("error") }) //ここは出力されない。
.then(() =>{}, () => { console.log("error") }) //ここは出力されない。
.then(() => {
throw new Error(); // 例外を投げる
})
.catch(() => { console.log("error in last catch") }) //ここは出力される。
ですので、Promiseチェーンのどこかで例外が発生した場合に最終的に補足して同じ処理を実行したい場合は、チェーンの最後にcatchメソッドで定義することになります。(もしくは、最後のthenメソッドの第2引数)
以下の場合ラストのthenは実行されるでしょうか?
new Promise((resolve) => {
// reject(40);
resolve();
})
.then(() => {
throw new Error();
})
.catch(() => { console.log("ここは出力される。") })
.then(() => { console.log("ここは実行される?") })
これは、実行されます。
catchもプロミスインスタンスを返します。
そして、chtchに渡されたのコールバックが正常に終了した場合は、そのインスタンスが "fulfilled"となるため、次のthenの第1引数を実行します。
Promiseインスタンスの状態
Promiseインスタンスは、3つの状態を持っています。
pending(待機状態)
fullfilled(履行状態)
rejected(拒否状態)
promiseインスタンスは作られた初期は Pending という状態になります。
①そして、resolveされた場合は Fullfilled 、rejectされた場合は Rejected になります。
②また、thenメソッドから作成されるPromiseインスタンスは、コールバック関数が正常処理終了したら Fullfilled となります。
(実は、②は①と同意ですが、今は②の挙動を理解しておいてください。)
const p = new Promise((resolve) => {
resolve(); //promiseインスタンス作成と同時にresolveされる。
})
console.log(p);
// => Promise {<fulfilled>: undefined}
const p = new Promise((resolve, reject) => {
reject();//promiseインスタンス作成と同時にrejectされる。
})
console.log(p);
// => Promise {<rejected>: undefined}
Executorに非同期関数が渡された場合。
※queueMicrotaskは、コールバックをマイクロタスクキューに発行します。つまり非同期で処理されます。
const p = new Promise((resolve) => {
queueMicrotask(() => {
resolve();
console.log(p); //② resolveされるとfullfilledに変わる。
})
})
console.log(p); //① まだresolveされていないので、pending
// ① Promise {<pending>}
// ② Promise {<fulfilled>: undefined}
上記の場合、resolveは一度【マイクロタスクキュー】に積まれるため、pには【pending(待機状態)】状態("fulfilled"でも"rejected"でもない状態)のpromiseインスタンスが一旦、格納されます。
よって、最終行のconsole.log(p)されたときには pending となります。
queueMicrotaskのコールバック内でconsole.log(p)された時には既に resolveされている ので状態は fulfilled になっています。
ここで、重要なポイントはresolve、rejectが実行されるのはPromiseインスタンスが pendingの状態 の時のみです。
一度promiseインスタンスが fulfilled もしくは rejected になった場合は resolveもrejectも実行されません。
そして、"resolve"も"reject"も実行されなくなるということは 状態が変わる事もありません。
const p = new Promise((resolve, reject) => {
queueMicrotask(() => {
resolve(); // 状態を"fulfilled"にする。
console.log(p); // => Promise {<fulfilled>: undefined}
reject(); // 既に"fulfilled"のため状態は変化させない。
console.log(p); // => Promise {<fulfilled>: undefined} 状態は"fulfilled"のまま
})
})
const p = new Promise((resolve, reject) => {
resolve(); //thenメソッドの第1引数のコールバックを実行。状態を"fulfilled"に変更。
reject(); //状態が"fulfilled"のため、thenメソッドの第2引数のコールバックは実行されない。
}).then(() => { console.log("正常") }, () => { console.log("異常") })
//=> 正常
以下の場合、出力される状態は何になるでしょうか?
const p = new Promise((resolve, reject) => {
resolve();
}).then(() => { console.log(p) })
答えは pending となります。
2行目で resolveしているので fulfilledになるのでは?と考えたかもしれません。
確かに、new promiseで作成されたインスタンスは、fulfilledになっています。
そして、そのインスタンスがthenメソッドを実行します。
つまり、3行目で実行される console.log(p)のpは、thenメソッドが返した新たなプロミスインスタンスとなります。
そして、これはconsole.log(p)を実行した時点では、コールバックの処理が完了しきっていない為、pにはpending状態のインスタンスが出力される訳です。
【thenメソッド】が実行されるタイミングについて
以下のコードで【then1,then2】の実行されるタイミングについてイメージできるでしょうか?
// pend関数の中身は非同期処理の説明と直接関係ないため詳しく見る必要はありません。
function pend(t = 1000) { //1000ミリ秒 同期ループ(コールスタックを占有)
const startTime = new Date();
let logged;
while (new Date() - startTime < t) {
const diff = new Date() - startTime
if (diff % 200 === 0 && diff !== logged) {// ログ出力するための処理なので関係ありません。
console.log(diff);
logged = diff;
}
}
}
const log = (n) => console.log(`run callback${n}`);
new Promise((resolve) => {
setTimeout(() => { resolve() }, 1000)
})
.then(() => {//then1
pend() //1000ミリ秒 同期ループ(コールスタックを占有)
log(1)
})
.then(() => { //then2
pend()//1000ミリ秒 同期ループ(コールスタックを占有)
log(2)
})
以下の様に思われた方はいますでしょうか?①
プログラム開始から
then1 => 2000ミリ秒後
then2 => 3000ミリ秒後
これは間違いです。
誤解された方は、以下のようにイメージしている可能性があります。(筆者も最初このようにイメージしていました。)
① promiseインスタンスがresolveされたらthenメソッドが実行される。
② thenメソッド内のコールバックが処理完了したらpromiseインスタンスが作成される。
function pend(t = 1000) {
const startTime = new Date();
let logged;
while (new Date() - startTime < t) {
const diff = new Date() - startTime
if (diff % 200 === 0 && diff !== logged) {
console.log(diff);
logged = diff;
}
}
}
const log = (n) => console.log(`run callback${n}`);
const p = new Promise((resolve, reject) => {
setTimeout(() => { resolve() }, 1000)
})
console.log(p);
const p1 = p.then(() => {//then1
pend()
log(1)
})
console.log(p1);
const p2 = p1.then(() => { //then2
pend()
log(2)
})
console.log(p2);
上記を実行すると、すぐに以下が出力されます。
p: Promise {<pending>}
p1: Promise {<pending>}
p2: Promise {<pending>}
ここでのポイントは undefined ではなく、すべて Promiseインスタンス が返っている点です。
つまり、Promiseチェーンにおいてすべてのthenメソッドは コールバックの処理が終了しているかに関係なく、同期的に実行 されるということです。
もちろんそれぞれのコールバックが実行されるタイミングは、逐次的に実行されるため処理のタイミングによります。
(今回の場合 "run callback" が出力されるのは、待機時間後になる)
あくまでthenメソッドの役割は 【成功時パターン】と【失敗時パターン】のコールバックを登録 し 【プロミスインスタンス】を返す事 です。
そして、これは 同期的にチェーンの最後 まで処理されます。
thenメソッドは 【コールバックを実行する】ではない事 を理解しておく必要があります。(例外がありますが、これは後ほど説明します。)
勘違いしてしまいがちな例
実際の挙動
上図の挙動はすべて 同期 で実行されます。
よって、例の様なプロミスチェーンが実行された場合 Executor関数やコールバックの同期・非同期にかかわらず、すべてのthenが同期 で実行されます。
thenメソッドは、コールバックを登録しますが 新しく作成したpromiseインスタンス ではなく、 呼び出し元のpromiseインスタンス に登録します。
そして、その 呼び出し元のPromiseインスタンスがresoveされた場合に登録されているコールバックが実行 されます。(rejectの場合も同様)
また、thenメソッドで返されるpromiseインスタンスは 必ず最初はpending の状態です。
これは当然で、Promiseチェーンのthenメソッドは 同期的に最後まで 実行されるので、 thenメソッドのコールバック が、 最後のthenメソッドの実行完了 まで、コールスタックに積まれることはなく(コールバックは一旦【マイクロタスクキュー】で順番待ちとなる)、作成されるpromiseインスタンスが "fulfilled"や"rejected" になっていることはあり得ません。
そして、逐次登録したコールバック関数が実行されていきます。
この時、コールバック関数を実行しているのは、thenメソッドではありません。Promiseインスタンス内で定義されている resolve もしくはreject です。
ここの詳細は後に説明いたします。
3: 擬似的なclassを作成して内部的に理解する
ここまで、Promise使い方を中心に解説しましたが疑問点を放置しているところもあります。
そこで、擬似的にPromiseクラスと同様の挙動をするMyPromiseという名前のクラスを作ってみて、実際にどの様なプロセスを得ているのか理解していきたいと思います。
以下の様にMyPromiseと名づけ最低限必要そうなプロパティーとメソッドを定義して雛形をつくりました。
以前以下のような疑問がありました。
Executor関数の引数のresolveっていつどうやって渡しているの?
その答えは、Promiseのclass内で定義されたresolve関数が、constructor実行時にexecutorに渡ってきます。
※図ではresolveのみに焦点を絞ってますが、rejectも同様です。
このことからも分かる様に、引数で定義する変数名はresolve、rejectでなくても問題ありません。
new MyPromise((seikou, shippai) => {
// seikouにはクラス内で定義されたresolve、shippaiにはクラス内で定義されたrejectが入ってくる。
try {
seikou();
} catch (e) {
shippai();
}
});
では、concludeされたら状態が変わる様に実装します。
resolve、reject共に引数を受け取り、それがthenメソッドのコールバックに渡ります。
コールバックに渡すためにvalueOnConcludeというプロパティーに格納しておきます。
では、thenメソッドを見ていきましょう。
まず、thenメソッドが呼び出された時に、既にpromiseインスタンスがconcludeされていた場合に、コールバック関数を実行するようにします。
なお、以前以下のように説明いたしました。
thenメソッドは 【コールバックを実行する】ではない事 を理解しておく必要があります。(例外がありますが、これは後ほど説明します。)
この例外とは、上記のようにthenメソッドの呼び出し時に、呼び出し元インスタンスが既にconcludeされていた場合です。
そして、それは最初にpromise newされた際にexecutorが同期でresolveを呼び出した場合となります。
次に進みます。
pendingの場合は、コールバックを予約しておき、concludeされた段階で実行する必要があります。
pendingの場合はコールバックを予約する処理を記載します。
pending状態でconcludeされた場合は、reservedFuncsに予約されているCBを実行します。
なお、以前以下のように説明いたしました。
この時、コールバック関数を実行しているのは、thenメソッドではありません。Promiseインスタンス内で定義されている resolve もしくはreject です。
ここの詳細は後に説明いたします。
ここで上記の疑問は解決しました。
次に進みます。
これで、executorが非同期処理であった場合でも、同期処理であった場合でもthenメソッドに予約したコールバックが実行できるようになりました。
今、thenメソッドはコールバックを "予約" or "実行"するだけで返り値は何もありません。
そうすると当然promiseチェーンで繋げることはできません。
promiseチェーンを繋げるには、thenメソッドは 必ずMyPromiseインスタンスを返す 必要があります。
new MyPromise((resolve, reject) => {
resolve("同期でresolveしたよ");
})
.then((v) => { console.log(v) }, (e) => { console.log(e) })
//このthenメソッドの返り値はundefined
.then(() => { console.log("next callback") })
//当然undefinedからthenメソッドは呼び出せない。
// => 同期でresolveしたよ
// => Uncaught TypeError: Cannot read properties of undefined (reading 'then')
一旦、executorが 同期処理 であった場合に、thenメソッドがPromiseインスタンスを返すように修正します。
返されるインスタンスはどのようなインスタンスでしょうか?
コールバック関数の返り値でconcludeされたインスタンス です。
つまり、stateに"fulfilled"、valueOnConcludeに"コールバック関数の返り値" が格納されたインスタンスです。
ポイントは、呼び出し元のプロミスが rejectであった場合でも、thenメソッドから返されるpromiseインスタンスは "resolve"されたインスタンス("fulfilled") になります。
考え方としては、thenメソッドの第2引数のコールバックを 正常に処理 したからです。
もちろん、コールバック内で例外が発生したらrejectedになります。
const resolve1st = () => console.log("resolve on 1st then");
const reject1st = () => console.log("reject on 1st then");
const resolve2st = () => console.log("resolve on 2st then");
const reject2st = () => console.log("reject on 2st then");
new Promise((resolve, reject) => { // ここで返されるpromiseインスタンスは"rejected"
reject();
})
.then(resolve1st, reject1st)
// 呼び出し元インスタンスが"rejected"なので第2引数のコールバック reject1st が実行される。
// reject1stが正常に実行された場合は、返されるpromiseインスタンスは"fullfilled"
.then(resolve1st, reject2st)
//呼び出し元インスタンスが"fullfilled"なので第1引数のコールバック resolve1st が実行される。
上記の場合、出力される文字列は下記となります。
reject on 1st then
resolve on 1st then
さて、現時点ではPromiseとMyPromiseで挙動が違う箇所があります。
Promiseはthenで登録されたコールバックは 【マイクロタスクキュー】 に積まれます。つまり非同期で実行されます。
しかし、MyPromiseは、すべて同期的に実行 されてしまってます。
Promiseの場合
new Promise((resolve, reject) => {
resolve();
})
.then(() => console.log("resolve on 1st then"))
.then(() => console.log("resolve on 2st then"))
console.log("last sync");
// last sync
// resolve on 1st then
// resolve on 2st then
MyPromiseの場合
new MyPromise((resolve, reject) => {
resolve();
})
.then(() => console.log("resolve on 1st then"))
.then(() => console.log("resolve on 2st then"))
console.log("last sync");
// resolve on 1st then
// resolve on 2st then
// last sync
同期的に実行されているためlast syncが最後にログ出力
queueMicrotask でラップしてコールバックを、マイクロタスクキュー に積む様にします。
また、例外が発生したらrejectするように修正します。
もし、コールバックの返り値がMyPromiseインスタンスだった場合はどうなるでしょうか?
つまり、以下の様な場合です。
new MyPromise((resolve, reject) => {
resolve("v");
})
.then((v) => {
return new MyPromise((resolve) => {
resolve(v + "!");
})
})
.then((v) => { console.log(v) });
//vにはMyPromiseインスタンスが渡ってくる。
意図としては 'v + !'を出力したいのですが、ログ結果は以下となります。
MyPromise {state: 'fulfilled', valueOnConclude: 'v!', reservedFuncs: null}
1回目のthenのコールバックの返り値は MyPromiseインスタンス となります。
そのPromiseインスタンス自体が、次のthenのvに渡るため promiseインスタンスそのもの がログ出力されています。
そこで、thenのコールバックの返り値が MyPromiseインスタンスだった場合には、以下の処理を追加します。
ここはかなり分かりづらいポイントだと思うので、一旦図解で整理します。
まず、『thenメソッドの返り値が MyPromiseインスタンス でない場合。(v+ "P!"の文字列)』
つまり以下のコードのような場合です。
new MyPromise((resolve, reject) => {
resolve("z");
})
.then((v) => {
return v + "!";
})
.then((v) => { console.log(v) });
まず、MyPromiseインスタンス①がnewされて、即時(同期的に)resolveされます。
new MyPromise((resolve, reject) => {
resolve("z"); //<= 🔴ここ
})
.then((v) => {
return v + "!";
})
.then((v) => { console.log(v) });
下記のようなインスタンスが作成されます。
以後、図中ではプロパティー名を以下のように略します。
status : s
valueOnConclude : v
reservedFuncs : rf
次に、作成されたインスタンス①がthenメソッドを呼び出します。
new MyPromise((resolve, reject) => {
resolve("z");
})
.then((v) => { //<= 🔴ここ
return v + "!";
})
.then((v) => { console.log(v) });
上記の流れを下記の図で説明します。
(1) ➡︎ (2) ... の順番で処理が進んでいきます。
ここで非常に重要なポイントがあります。
マイクロタスクキューには以下の queueMicrotaskの引数 に定義された関数が積まれます。
() => {
try {
const resultFromCB = callBack(this.valueOnConclude);
if (resultFromCB instanceof MyPromise) {
resultFromCB.then((v) => resolve(v), (e) => reject(e));
} else {
resolve(resultFromCB);
}
} catch (err) {
reject(err);
}
ここでの thisやresolve、reject は一体どこからきているのかを理解しておく必要があります。
作成されたMyPromiseインスタンスは、さらにthenメソッドを呼び出します。
new MyPromise((resolve, reject) => { // インスタンス①が作成される。
resolve("z");
})
.then((v) => { // インスタンス②が作成される。
return v + "!";
})
.then((v) => { console.log(v) }); //<= 🔴ここ
すべてのthenが処理終了 した(コールスタックが空になった)ので マイクロタスクキューに積まれている関数 がコールスタックに移されて実行されます。
上記のプロセスを得て最終的に z! がログ出力されます。
では、本題のthenメソッドのコールバックが【MyPromiseインスタンス】を返す場合の処理をみていきます。
new MyPromise((resolve, reject) => {
resolve("v")
})
.then((v) => {
return new MyPromise((resolve) => { // thenメソッドのコールバックがMyPromiseインスタンスを返す。
resolve(v + '!')
})
})
.then((v) => { console.log(v) }, (e) => { console.log(e); });
まず、MyPromiseインスタンス①がnewされて、即時(同期的に)resolveされます。
new MyPromise((resolve, reject) => {
resolve("z"); //<= 🔴ここ
})
.then((v) => {
return new MyPromise((resolve) => {
resolve(v + '!')
})
}
)
.then((v) => { console.log(v) });
インスタンス①が作成されます。
次に、作成されたインスタンス①がthenメソッドを呼び出し、インスタンス②が作られます。
すべてのthenメソッドが呼び出されたので(コールスタックが空になったので)MQに積まれた関数が実行されます。
callbackが実行されてインスタンス③が作成されます。
かなりややこしいですが、イメージとしては返り値として渡された インスタンス③のthenメソッド に、 インスタンス②を解決するresolve を渡しておき、 インスタンス③が解決 されたら、 渡していたresolve(インスタンス②を解決するための) を実行してもらい、結果 インスタンス②が解決 されます。
このようにして、MyPromiseのインスタンスをチェーンで繋いでいきます。
さて、途中でも説明したように、今の実装だと 呼び出し元インスタンスがpending だった場合にthenメソッドは 新たなMyPromiseインスタンス を返しません。
今、下記のようなコードはエラーとなります。
new MyPromise((resolve, reject) => {
resolve("z")
})
.then((v) => {//コールバックは必ずマイクロタスクキューを経由して実行されるのでpendingの状態で次のthenを呼び出す。
return v + '!'
})
.then((v) => { //このthenはMyPromiseインスタンスを返さない。
return v + '!'
})
.then((v) => { console.log(v) }, (e) => { console.log("【ログ: e】", e); }); //当然このthenメソッドを呼び出すことはできない。
// => Uncaught TypeError: Cannot read properties of undefined (reading 'then')
以下の様に修正します。
9_reserve_conclder_of_createdPromise_by_then
前回との差分
reservedFuncsに newされたインスタンス内で定義されているresolve,reject を渡します。
発想としては、上記と同じですね。
reservedFuncs.onFulfilledCBがMyPromiseインスタンスだった場合は、先程と同じ様にさらに、そのインスタンスのthenにconcluderを引き継ぐ様に修正します。
考え方は今までの流れと同じですね。
10_then_of_resultFromCB
前回との差分
ここまで来たら、あと一歩です。
今、現状だと同じインスタンスからthenを複数回呼び出した場合、最後に引数で渡されたコールバック しか実行されません。
reservedFuncsが 上書き されてしまうからです。
const samePromise = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve()
})
})
samePromise.then(() => { console.log("one") });
samePromise.then(() => { console.log("two") });//ここでsamePromiseのreservedFuncsが上書きで登録される。
// => two
まず、reservedFuncsを複数登録できるように配列とします。
thenメソッド呼び出し時に呼び出し元インスタンスがpendingの場合は、reservedFuncsに格納していましたが、配列に追加する形にします。
そして、concludeされた場合にループでreservedFuncsを実行するように修正します。
次に、thenメソッドに与えられた引数が関数ではなかった場合のケアをします。
現状だと、以下の様にresolveの値経由しません。
new MyPromise((resolve, reject) => {
resolve('a')
}).then("not function").then((v) => { console.log(v) });
// => undefined
thenの引数が、関数でなかった場合は、concludeされた値をそのまま返す関数に差し替えます。
new MyPromise((resolve, reject) => {
resolve('a')
}).then("not function").then((v) => { console.log(v) });
// => a
最後にchathメソッドを実装します。
chathメソッドはthenの第二引数のシンタックスシュガーであるため、以下の様に実装します。
Promiseクラスの静的メソッドについて
Promiseクラスは、いくつかの静的メソッドを持ちます。
resolve, reject
これは単純に与えられた引数でresolve、rejectされたプロミスインスタンスを返します。
これまで、インスタンス作成後、同期的にresolveされる場合には以下のように記述していました。
new Promise((resolve, reject) => {
resolve("resolve!!");
})
実は、Promiseはこれと同じ結果を返す静的メソッドを持っています。
Promise.resolve("resolve!!")
以下の様に定義します。
static resolve(v) {
return new MyPromise((resolve) => {
resolve(v);
});
}
static reject(v) {
return new MyPromise((_, reject) => {
reject(v);
});
}
race
引数に promiseを返す関数を配列 で渡し、1番最初にconcludeされた結果 が返却されます。
どのpromiseから返されたか分かりません。
また、最初のconcludeがrejectならrejectされます。
以下の様に定義してみます。
static race(promises) {
return new MyPromise((resolve, reject) => {
for (const promise of promises) {
let p = promise;
if (!(p instanceof MyPromise)) {
p = MyPromise.resolve(p);
}
// 後続のインスタンスがconcludeされても影響はない
p.then(
(v) => {
resolve(v);
},
(e) => {
reject(e);
}
);
}
});
}
ループで回されたPromiseインスタンスで それぞれ、thenメソッドが実行 されます。
最初にconcludeされたPromiseインスタンスが第1引数を実行し、 return new MyPromiseで返されるPromiseインスタンス のresolveを実行します。(rejectの場合も同様)
promiseは、 1度状態が変更されたら2度と変更されない ので、後続のPromiseインスタンスがconcludeされても影響はありません。
any
引数に promiseを返す関数を配列 で渡し、1番最初にfullfilledとなった結果 が返却されます。
どのpromiseから返されたか分かりません。
全てrejectならエラーメッセージを返します。
static any(promises) {
return new MyPromise((resolve, reject) => {
let rejectedCount = 0;
for (const promise of promises) {
let p = promise;
if (!(p instanceof MyPromise)) {
p = MyPromise.resolve(p);
}
p.then(
(v) => {
resolve(v);
// 後続のインスタンスがconcludeされても影響はない
},
() => {
rejectedCount++;
// すべて渡されたインスタンスがrejectされた場合のみ、rejectする
if (rejectedCount >= promises.length) {
reject("AggregateError: All promises were rejected");
}
}
);
}
});
}
ループで回されたPromiseインスタンスで それぞれの、thenメソッドが実行 されます。
最初にconcludeされたPromiseインスタンスが第1引数を実行し、 return new MyPromiseで返されるPromiseインスタンス のresolveを実行します。
rejectの場合は、rejectedCount を加算します。
もし、rejectedCountが渡されたインスタンの数以上(つまり、すべてrejectされた)場合は、 return new MyPromiseで返されPromiseインスタンスのreject を実行します。
all
引数に promiseを返す関数を配列 で渡し、全ての結果がfulllfiledされる のを待ちます。
rejectされたら、その時点で .catch へ流れます。
返される結果の順番は 引数で渡された順番と同じ になります。
static all(promises) {
return new MyPromise((resolve, reject) => {
const resolvedValues = Array(promises.length);
let fulfilledCount = 0;
for (let i = 0; i < promises.length; i++) {
let p = promises[i];
if (!(p instanceof MyPromise)) {
p = MyPromise.resolve(p);
}
p.then(
(v) => {
resolvedValues[i] = v;
// 値を配列で格納しておく順番は引数で渡された順番(解決順ではない)
fulfilledCount++;
// すべてresolveされたらresolvedValuesでresolveする
if (fulfilledCount >= promises.length) {
resolve(resolvedValues);
}
},
(e) => {
reject(e);
}
);
}
});
}
ループで回されたPromiseインスタンスで それぞれの、thenメソッドが実行 されます。
それぞれが、resolveされたらresolvedValuesにresolveされた値を格納し、fulfilledCount_ を加算します。
fulfilledCountがPromiseインスタンスの数を超えたら return new MyPromiseで返されPromiseインスタンス をresolveします。
allSettled
引数に promiseを返す関数を配列 で渡し、 全ての結果をオブジェクト形式 で返します。
static allSettled(promises) {
return new MyPromise((resolve, reject) => {
let concludedCount = 0;
const results = Array(promises.length);
function settle(i, v, isResolve) {
// concludeによってオブジェクトを返す
results[i] = isResolve
? { status: "fulfilled", value: v }
: { status: "rejected", reason: v };
concludedCount++;
// すべてconcludeされたらresolve
if (concludedCount >= promises.length) {
resolve(results);
}
}
for (let i = 0; i < promises.length; i++) {
let p = promises[i];
if (!(p instanceof MyPromise)) {
p = MyPromise.resolve(p);
}
p.then(
(v) => {
settle(i, v, true);
},
(e) => {
settle(i, e, false);
}
);
}
});
}
ループで回されたPromiseインスタンスで それぞれの、thenメソッドが実行 されます。
concludeの結果に応じたオブジェクトをそれぞれ作成し、resultsに格納します。
全てのPromiseインスタンスがconcludeされたら、return new MyPromiseで返されPromiseインスタンス をresultsでresolveします。
async/await
ではasync awaitについて考えます。
promiseを理解できているならasync/awaitを理解することは容易いです。
なぜならasync/awaitは単にpromiseを書きやすくした構文だからからです。(厳密には違う様ですが最初はその様に理解したほうがスムーズです。)
async関数は 必ずPromiseインスタンス を返します。
returnされる値が Promiseインスタンスではない場合 は Promiseインスタンスでラップ されて返却されます。
asyncについて
実際に見てみましょう。
function func() {
return "a"
}
console.log(func())
この場合出力は当然下記となります。
a
では、以下の様に関数の前に async を付けます。
async function func() {
return "a"
}
console.log(func())
下記の様にaでfullufilledにcocludeされたPromiseが返却されていることがわかります。
PromiseState: "fulfilled"
PromiseResult: "a"
プロミスをreturnした場合は、そのまま返されます。
async function func() {
return new Promise((resolve, reject) => {
resolve("a")
})
}
console.log(func())
PromiseState: "fulfilled"
PromiseResult: "a"
asyncが関数名の前についた場合は以下の様なrapperAsyncでラップされるイメージです。
//以下の様な関数でラップするイメージ
function rapperAsync(func) {
const returnValue = func();
// 関数の返り値がPromiseインスタンスの場合はそのまま返す。
if (returnValue instanceof Promise) {
return returnValue;
// 関数の返り値がPromiseインスタンスでない場合は、返り値でresolveしたインスタンスを作成して返す。
} else {
return new Promise((resolve, reject) => {
resolve(returnValue)
})
}
}
function exampleFunc() {
return "b"
}
//exampleFuncをrapperAsyncでラップ
console.log(rapperAsync(exampleFunc);
promiseインスタンスを返すということは、当然 thenでpromiseチェーンをつなぐ事 が可能となります。
async function func() { //必ずPromiseインスタンスを返す。
return "a"
}
func().then((v) => { console.log(v) })
a
awaitは、 thenのシンタックスシュガー のイメージです。
まず以下のコードを見てみましょう。
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve("a") }, 1000)
})
}
const a = func();
console.log(a)
pendingのプロミスが出力されますね。
Promise {<pending>}
非同期処理の解決後処理をしたい場合は、下記の様にthenを繋げばよいですね。
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve("a") }, 1000)
})
}
func().then((v) => { console.log(v) })
これを await を使えばもっと直感的に書く事ができます。
<script type="module">
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve("a") }, 1000)
})
}
- func().then((v) => { console.log(v) });
+ const v = await func();
+ console.log(v);
</script>
関数実行の前に await を記述することにより thenと同様に 関数funcがconcludeされた後 に値を返します。
thenの場合は解決された値は コールバックの引数に渡ります が、awaitの場合は、そのまま返り値 となります。
また、 funcがconcludeされるまで、以後に書かれた処理は実行されません。 つまり、以後の処理はthenのコールバック と同じです。
以下の様なpromiseチェーンで繋いだコードがあります。
async function func(v) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(v + "!");
}, 1000)
})
}
func("a").then(
(v) => {
console.log(v);
return func(v);
}
).then(
(v) => {
console.log(v);
return func(v);
}
).then(
(v) => {
console.log(v);
return func(v);
}
)
// 1秒おきに出力
a!
a!!
a!!!
これをasync/awaitを用いて書き直して見ます。
async function func(v) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(v + "!");
}, 1000);
})
}
const v = await func("a");
console.log(v);
const v1 = await func(v);
console.log(v1);
const v2 = await func(v1);
console.log(v2);
直感的で、見やすくなったと思います。
イメージとしては thenの第1引数コールバック が await以後の処理 になった感じです。
また、非同期処理の返り値が特に必要ない場合は、変数で返り値を格納する必要はありません。
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("a");
resolve();
}, 1000);
})
}
await func();
console.log("b");
下と同じ
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("a")
resolve();
}, 1000);
})
}
func().then(() => { console.log("b") })
//1秒後
a
b
なお、実は上記のコードは Top-level await というものを用いています。
元々、awaitは async関数の中 でしか使用できませんでした。
function func() {
return "a";
}
const a = await func();
console.log(a);
Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules (at async1.html:89:13)
トップレベルでawaitを使用するにはasync関数でラップする必要がありました。
//一旦無名のasync関数でラップして実行する。
(async function () {
function func() {
return "a";
}
const a = await func();
console.log(a);
})()
a
しかし、ES2022からasync関数の中でなくてもawaitが使える様になりました。
ただし、この場合<script type="module">で読み込み必要があります。
async/awaitでの例外処理
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject();
}, 1000);
})
}
await func();
console.log("error発生");
Uncaught undefined
console.log("error発生") は、実行されません。
なぜなら、await以降は、 thenの第1引数と同じ なので、 rejectが呼び出された場合 は当然実行されません。
async/awaitで例外処理をするには、通常と同じ try/chatch で行うことができます。
async function func() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject();
}, 1000);
})
}
try {
await func();
console.log("then1");
} catch {
console.log("error発生")
}
error発生
この場合は、console.log("then1")は実行されません。(thenの第1引数が実行されないのと同様)
以上で、この記事は終了となります。
非同期処理の理解について、少しでも寄与できたら幸いです。
今回作成した擬似クラスは下記のレポジトリで公開しています。
my_promise