JavaScriptの非同期処理Promise、AsyncとAwaitの仕組みをGIFアニメで解説
Post on:2020年5月19日
JavaScriptの非同期処理Promise、AsyncとAwaitの仕組みをGIFアニメで解説した記事を紹介します。
⭐️🎀 JavaScript Visualized: Promises & Async/Await
by Lydia Hallie
下記は各ポイントを意訳したものです。
※当ブログでの翻訳記事は、元サイト様にライセンスを得て翻訳しています。
はじめに
JavaScriptのコードが期待通りに実行されないことに悩まされたことはないですか? おそらく、関数が不規則に実行されたり、予測できないタイミングで実行されたり、実行が遅れたりしたことがあるかもしれません。そして、ES6で導入された新機能Promiseが原因かもしれません!
何年も前からの私の好奇心が報われ、眠れない夜が再びGIFアニメーションを作る時間を与えてくれました。Promiseについて、なぜ使うのか、どのように(裏で)機能しているのか、モダンな方法で書くにはどうすればよいのか、について話してみたいと思います。
もしあなたがまだJavaScriptのイベントループについての前回の記事を読んでいないのであれば、まずはそちらを読むことをお勧めします。
翻訳版: JavaScript イベントループの仕組みをGIFアニメで分かりやすく解説
ここでは、コールスタック、Web API、キューに関する基本的な知識があると想定して、イベントループを取り上げますが、今回はいくつかエキサイティングな追加機能についても解説します。
コールバック地獄
JavaScriptを書いていると、他のタスクに依存するタスクを処理しなければならないことがよくあります。例えば、画像を取得して、圧縮して、フィルターを適用して、保存したいとします📸
まず最初にすることは、編集する画像を取得することです。これはgetImage関数で処理できます。画像が正常に読み込まれたら、その値をresizeImage関数に渡すことができます。画像のサイズ変更が成功したら、applyFilter関数で画像にフィルターを適用します。画像を圧縮してフィルターを加えた後は、画像を保存して、すべてが正しく機能したことをユーザーに通知します🥳
これらの処理をコードにすると下記のようになります。
画像処理のコード
うーん、、、いいのですが、あまりよろしくありません。ネストされたコールバック関数がたくさんあり、これらは前のコールバック関数に依存しています。これは「コールバック地獄(Callback Hell)」と呼ばれ、大量のネストしたコールバック関数でコードを読みにくくしています。
幸いなことに、わたし達を助けてくれるPromiseと呼ばれるものがあります!
Promiseとは何か、このような状況でどのように助けてくれるのかを見てましょう😃
Promiseの構文
ES6で、Promiseが導入されました。
多くのチュートリアルでは、次のように説明されています。
Promiseとは、将来のある時点で解決または拒否することができる値のプレースホルダーのことです。
えーと、私はこの説明では意味が分かりませんでした。それどころか、Promiseとは曖昧で、予測不可能な魔法のように感じました。
Promiseとは何であるかを実際に見てましょう。
コールバックを受け取るPromiseコンストラクタを使用して、Promiseを作成することができます。実際に試してみましょう!
これは何が返ってきたと思いますか?
Promiseは、ステータス([[PromiseStatus]])と値([[PromiseValue]])を含むオブジェクトです。上記の例では、[[PromiseStatus]]の値は"pending"(待機)で、Promiseの値はundefined(未定義)であることが分かります。
心配しないでください。このオブジェクトを操作する必要はなく、[[PromiseStatus]]と[[PromiseValue]]プロパティにアクセスすることもできません。しかし、これらのプロパティの値はPromiseを扱うときに重要です。
PromiseStatusの値、状態は3つのいずれかになります。
- ✅fulfilled: Promiseがresolvedになり、処理が成功した状態。すべてがうまくいって、Promiseの範囲内でエラーは発生しませんでした🥳
- ❌rejected: Promiseがrejectedになり、処理が失敗した状態。何かがうまくいきませんでした。
- ⏳pending: pending初期状態。成功も失敗もしていません。
これは素晴らしいことのように聞こえますが、Promiseの状態が「pending」、「fulfilled」もしくは「rejected」になるのはいつでしょうか? そして、なぜこれらの状態が重要なのでしょうか?
上記の例では単純なコールバック関数() => {}をPromiseコンストラクタに渡しています。しかし、このコールバック関数は実際には2つの引数を受け取ります。最初の引数の値はresolveまたはresと呼ばれ、Promiseがresolve成功した時に呼び出されるメソッドです。2番目の引数の値はrejectまたはrejと呼ばれ、Promiseがreject失敗した時に呼び出されるメソッドです。
resolveまたはrejectメソッドのいずれかを呼び出した時にログに記録されるのを確認してみましょう。下記の例では、resolveメソッドをres、rejectメソッドをrejで使用しています。
"pending"(待機中)のステータスとundefined(未定義)の値を取り除く方法がようやく分かりました。resolveメソッドを呼び出した場合はPromiseのステータスは"fulfilled"(成功)になり、rejectedメソッドを呼び出した場合はPromiseのステータスは"rejected"(失敗)になります。
Promiseの値、[[PromiseValue]]の値は、resolvedまたはrejectedメソッドに引数として渡す値です。
私はJake Archibaldにこの記事の校正を依頼しました。そこで彼はステータスが"fulfilled"ではなく、"resolved"と表示されているChromeのバグがあることを指摘しました。Mathias Bynensのおかげで、このバグはCanaryで修正されました🥳🕺🏼
Chrome and Safari call this a "resolved" promise, which is true, but kinda misleading… pic.twitter.com/vfRI2Nkxw0
— Jake Archibald (@jaffathecake) April 9, 2020
これで曖昧だったPromiseオブジェクトの制御方法が少し分かりました。しかし、これは何に使われるのでしょうか?
さきほど、画像を取得して圧縮してフィルターを適用して保存する例を示しました。結局、それはネストされたコールバック地獄になりました。
そうです! Promiseはこの問題を解決するのに役立ちます。まず、コードブロック全体を書き換えて、各関数が代わりにPromiseを返すようにします。
画像が読み込まれて問題がなければ、Promiseはresolve成功です。そうでない場合、読み込み中にエラーが発生した場合はPromiseはreject失敗となります。
これをターミナルで実行するとどうなるか見てみましょう!
いいですね、予想通りです!
解析されたデータの値とともにPromiseが返されています。
しかし、ここからどうすればよいのでしょうか?
わたし達が気にするのはそのPromiseオブジェクト全体ではなく、データの値だけです! 幸いにも、Promiseの値を取得する3つのメソッドがあります。
- .then(): Promiseは、resolved成功後に呼び出されます。
- .catch(): Promiseは、rejected失敗後に呼び出されます。
- .finally(): Promiseは、resolvedまたはrejectedに関わらず、常に呼び出されます。
.thenメソッドは、resolveメソッドに渡された値を受け取ります。
.catchメソッドは、rejectedメソッドに渡された値を受け取ります。
最後に、Promiseオブジェクト全体がなくても、Promiseによってresolved成功された値があります。この値で何でも好きなことができます。
参考までに、Promiseが常にresolve成功または常にreject失敗されることが分かっている場合は、Promiseをresolveまたはrejectする値をPromise.resolveまたはPromise.rejectと書くことができます。
Promise.resolveまたはPromise.rejectと書くことができる
この構文はよく目にしますね😄
getImageの例では、実行するために複数のコールバックをネストする必要がありました。幸いなことに、.thenハンドラーはネストを回避できます🥳
.thenの結果自体がPromise値で、これは必要な数だけ.thenをチェーンできることを意味します。前のthenコールバックの結果は、引数として次のthenコールバックに渡されます!
.thenをチェーンできる
getImageの例では、処理された画像を次の関数に渡すために、複数のthenコールバックをチェーンすることができます。多くのネストされたコールバックの代わりに、クリーンなthenチェーンで記述できます。
thenチェーンでクリーンなコードに
これで完璧です!
この構文はネストされたコールバックよりもはるかに優れています。
イベントループ: Microtaskと(Macro)task
Promiseを作成する方法と値を抽出する方法を少し理解できたと思います。スクリプトにコードを少し追加して、もう一度実行してみます。
ちょっと待って!?🤯
最初に「Start!」が記録されました。これは、最初の行にconsole.log('Start!')があるからです。しかし、ログに記録された2番目の値は「End!」で、resolve成功された「Promise!」ではありません! そして「End!」の後に、「Promise!」が記録されています。何が起こっているのでしょうか?
ついにPromiseの真の実力が見えてきました🚀 JavaScriptはシングルスレッドですが、Promiseを使うことで非同期動作を追加することができます!
でもちょっと待ってください、前に見たことはありませんか?🤔 JavaScript イベントループの仕組みをGIFアニメで分かりやすく解説で、ブラウザにネイティブなメソッド(setTimeoutなど)を使用して何かしらの非同期動作を作成することはできませんか?
そうです!ただし、イベントループには(Macro)taskキュー(単にtaskキューと呼ばれる)とMicrotaskキューの2種類があります。(Macro)taskキューは(Macro)task用で、MicrotaskキューはMicrotask用です。
では、(Macro)taskとMicrotaskとは何でしょうか?
ここで紹介する以外にもいくつかありますが、一般的なものは下記の通りです。
(Macro)task | setTimeout | setInterval | setImmediate |
Microtask | process.nextTick | Promise callback | queueMicrotask |
MicrotaskのリストにPromiseがあります😃 Promiseがresolve成功して、then(), catch(), finally()メソッドを呼び出すと、そのメソッド内のコールバックがMicrotaskキューに追加されます! つまり、then(), catch(), finally()メソッド内のコールバックはすぐに実行されるわけではなく、JavaScriptコードに非同期動作が追加されることを意味します。
では、then(), catch(), finally()のコールバックはいつ実行されるのでしょうか?
イベントループはタスクに異なる優先順位を与えます。
- 現在コールスタックにあるすべての関数が実行されます。これらの関数が値を返すと、スタックからポップされます。
- コールスタックが空の場合は、キューに入っているMicrotaskが一つずつ呼び出しスタックにポップされ、実行されます!
Microtask自体も新しいMicrotaskを返すことができ、無限のMicrotaskループを作ることができます😬 - コールスタックとMicrotaskキューの両方が空の場合は、イベントループは(Macro)taskキューにタスクが残っているかどうかをチェックします。タスクはコールスタックにポップされ、実行され、ポップオフされます!
簡単な例を見てみましょう。
- Task 1: 例えば、コード内ですぐに呼び出すなどして、コールスタックにすぐに追加される関数です。
- Task 2, Task 3, Task 4: Microtask、例えばPromiseのthenコールバック、あるいはqueueMicrotaskで追加されたタスクなどです。
- Task 5, Task 6: (Macro)task、例えばsetTimeoutやsetImmediateコールバックなどです。
最初に、Task 1が値を返し、コールスタックからポップオフされます。次にエンジンはMicrotaskキューに待機中のTaskをチェックします。すべてのTaskがコールスタックに配置され、最終的にポップオフされると、エンジンは(Macro)taskキューにあるTaskをチェックし、コールスタックにポップされ、値を返すとポップオフされます。
ピンクのボックスはもう十分ですね、実際のコードを見てみましょう!
このコードでは、(Macro)taskのsetTimeoutとMicrotaskのPromiseのthen()コールバックがあります。エンジンがsetTimeout関数の行に到達すると、このコードを順番に実行するので、ログにどう記録されるか見てましょう。
参考までに、次の例ではconsole.log, setTimeout, Promise.resolveなどのメソッドがコールスタックに追加されていることを示しています。これらのメソッドは内部メソッドで、実際にはスタックトレースには表示されません。そのため、デバッガーで表示されなくても心配はいりません。定型コードを追加しなくても、このコンセプトの説明が簡単になるだけです。
最初の行でエンジンはconsole.log()メソッドを検出します。これはコールスタックに追加され、その後コンソールに「Start!」が記録されます。メソッドはコールスタックでポップオフされ、エンジンは続行されます。
最初の行で、console.log()を検出
次にエンジンは、setTimeoutメソッドを検出し、コールスタックにポップされます。setTimeoutメソッドはブラウザのネイティブメソッドで、タイマーが終了するまで、そのコールバック関数(() => console.log('Timeout!'))はWeb APIに追加されます。タイマーには0を指定しましたが、コールバックは最初にWeb APIにプッシュされ、その後(Macro)taskキューに追加されます。setTimeoutは(Macro)taskです!
setTimeoutを検出
次にエンジンは、Promise.resolve()メソッドを検出します。Promise.resolve()メソッドはコールスタックに追加され、その後Promise!の値をresolve成功します。then()コールバック関数はMicrotaskキューに追加されます。
Promise.resolve()を検出
最後にエンジンは、console.log()メソッドを検出します。これはすぐにコールスタックに追加され、値「End!」がコンソールに記録されます。コールスタックからポップオフされ、エンジンが続行されます。
console.log()を検出
これでエンジンはコールスタックが空になったことを認識します。コールスタックが空なので、Microtaskキューにタスクがあるかどうかを確認します。残ってますね、Promiseのthenコールバックが順番待ちしています。コールスタックにポップされ、Promiseのresolve成功された値(Promise!という文字列)がログに記録されます。
Microtaskキューにタスクがあるかどうかを確認
エンジンはコールスタックが空であることを認識しているので、タスクがキューに入っているかどうかを確認するためにもう一度Microtaskキューを確認します。残ってないですね、Microtaskキューはこれで空になりました。
今度は(Macro)taskキューを確認します。setTimeoutコールバックがまだ残っています!setTimeoutコールバックはコールスタックにポップされます。コールバック関数はconsole.logメソッドを返し、Timeout!という文字列がログに記録されます。setTimeoutコールバックは、コールスタックからポップオフされます。
(Macro)taskキューにタスクがあるかどうかを確認
これで完了です!🥳
この出力結果は、驚くことではなかったようです。
AsyncとAwait
ES7ではJavaScriptに非同期動作を追加する新しい方法が導入され、Promiseでの作業がより簡単になりました。asyncとawaitというキーワードの導入により、暗黙的にPromiseを返す非同期のasync関数を作成できます。しかし、どうすればそんなことが実現できるのでしょうか?😮
明示的にPromiseオブジェクトを使用してPromiseを作成できることを確認しましたが、それはnew Promise(() => {}), Promise.resolve, Promise.rejectのいずれを記述しても同じです。
Promiseオブジェクトを明示的に使用する代わりに、暗黙的にオブジェクトを返す非同期関数を作成できるようになりました。つまり、Promiseオブジェクトを書く必要がないことを意味します。
Promiseオブジェクトを書かずに、暗黙的に非同期関数を作成できる
非同期のasync関数が暗黙的にPromiseを返すのは非常に素晴らしいことですが、async関数の真のパワーはawaitキーワードを使用したときに見ることができます!awaitを使用すると、非同期関数を一時停止させて、await待機中の値がresolve成功されたPromiseを返すのを待つ間、非同期関数を一時停止させることができます。前述のthen()コールバックで行ったように、resolve成功されたPromiseの値を取得したい場合は、await待機中のPromiseの値に変数を代入することができます!
非同期のasync関数を一時停止できるとは、どういう意味でしょうか?
以下のコードを実行するとどうなるか見てみましょう。
何が起きていると思いますか?
最初にエンジンはconsole.logを検出します。コールスタックにポップされ、その後「Before function!」がログに記録されます。
次に、非同期のasync関数myFunc()を呼び出し、myFuncの関数本体を実行します。関数本体の最初の行で、別のconsole.logを呼び出し、今度は「In function!」がログに記録されます。console.logはコールスタックに追加され、値をログに記録してから、ポップオフされます。
関数本体は実行され続け、2行目に移動します。最後に、awaitキーワードが出来てました🎉
まず最初に起こることは、await待機中の値が実行されることです。この場合はone()関数です。コールスタックにポップされ、最終的にresolve成功されたPromiseを返します。Promiseがresolve成功されてoneを返すと、エンジンはawaitキーワードに遭遇します。
awaitキーワードを検出すると、非同期のasync関数は一時停止します✋🏼 関数本体の実行は一時停止され、非同期のasync関数の残りは通常のタスクではなくMicrotaskで実行されます。
これで非同期のasync関数myFuncがawaitキーワードに遭遇したので、エンジンは非同期関数からジャンプして、非同期のasync関数が呼び出された実行コンテキストでコードを実行します。この場合はグローバル実行コンテキストです🏃🏽♀️
これで、グローバル実行コンテキストで実行されるタスクはもうありません! イベントループは、キューに入れられたMicrotaskがあるかどうかを確認します。非同期のmyFunc関数はoneの値を解決した後にキューに入れられます。myFuncはコールスタックに戻り、以前に中断されたところから実行を続けます。
変数resは最終的にその値、つまりoneが返されたresolve成功されたPromiseの値を取得します! resの値でconsole.logを起動します。この場合は文字列「One!」です。「One!」がコンソールに記録され、コールスタックからポップオフされます😊
ようやく完了です。非同期のasync関数がPromiseのthenと比較してどのように異なるか気がつきましたか? awaitキーワードはasync関数を一時停止しますが、もしthenを使用していればPromise本体は実行され続けていたでしょう!
ここまで非常に多くの情報だったと思います🤯 Promiseを使うときに、少し戸惑うことがあっても心配はいりません。個人的には、非同期JavaScriptで作業するときに自信を持つには経験が必要だと感じています。
ただし、非同期JavaScriptで作業するときに遭遇するかもしれない「予期せぬ」「予期できない」動作が少しでも理解できるようになることを願っています!
不明点があれば、@lydiahallieまで気軽に連絡してください!😊
sponsors