@ 古曳 純基
どうもどうも、じゅんきです。
最近、Firebaseの学習に伴って「非同期処理」が頻繁に出てくるようになったので、今回は非同期処理の主役といっても過言ではないPromiseについて説明していくっ!
いつもながら、軽くメンタルモデルを作っときます。
1, 非同期処理とは複数の処理を同時に行う処理のことである
2, Promiseとは非同期処理の言語を同期的に扱いたいというユースケースで使うオブジェクトである
3, Promiseと.then
のシンタックスシュガーがasync / await構文である
この2点をとりあえず頭に入れておいてください。
そもそも同期処理とはなんでしょうか?
言葉は知ってても説明できる人は意外といないんじゃないかと思います。
前提として、基本的にプログラムというのは上から下に向かって順番に読み込まれていきます。
このとき、どんなに時間がかかる処理があっても先に進まずにその処理が終わるのを待ちます。
こう言った処理のことを同期処理と言います。
図にすると以下のようなイメージになります。
同期処理について説明しましたが、これにはデメリットもあります。
それは、「サーバー側にクエリを飛ばしてデータを取ってくる処理」や「多大な計算を必要とする処理」など時間のかかる処理においても処理が終わるのを待つので、全体の処理が終わるのが遅くなるという点です。
JavaScriptでは、データのフェッチや計算の他にもブラウザの見た目に関する処理もあるので、時間のかかる処理をいちいち待っていると画面がフリーズしたように見え、UXを著しく下げるかもしれません。
そこで、時間のかかる処理を他の処理と並行して行うことで時間が短縮できるんじゃね?と考える人が現れました。
非同期処理では、同期処理のように上から下に向かって処理の評価をしていきますが、時間がかかる処理があれば、その処理の終了を待たずに次の処理の評価を始めます。
図にすると以下のようになります。
同期処理と非同期処理がどのようなものかある程度理解できたと思います。
非同期処理を行う上で、時間がかかる処理が終了してからでないと次の処理がができないというケースもあります。
例を挙げると、サーバーから画像データを取ってきてフロントで表示すると言ったケースが挙げられます。
こう言ったケースの非同期処理のハンドリングを解決するために、コールバック関数が使用されていました。
コールバック関数とは、関数の引数になっている関数のことでしたね!
高階関数(引数に関数を持つ関数)が実行されなければ、コールバック関数は実行されないという仕組みが非同期処理を同期的にハンドリングするのに非常に向いていました。
例を挙げてみましょう。以下にコードを示します。
setTimeout(() => {
console.log("Hello")
setTimeout(() =>{
console.log("こんにちは")
setTimeout(() => {
console.log("你好")
},3000);
},2000);
},1000);
コンソール画面は以下のようになりました。
コードの解説をします。
まず、setTimeout()関数は、非同期処理(時間がかかる)の関数で、第2引数の秒数[ ms ]が経ってから第1引数のコールバック関数が発火します。
(1) はじめに1番外側のsetTimeout()が走ります。1000ms経つと第1引数のコールバック関数が発火します。
そのため、「Hello」が1番目にコンソール画面に出力されます。
(2) 次に、2つ目のsetTimeout()が走ります。2000ms経つと第1引数のコールバック関数が発火します。
そのため、「こんにちは」が2番目にコンソール画面に出力されます。
(3) 最後に、3つ目のsetTimeout()関数が走ります。3000ms経つと第Ⅰ引数のコールバック関数が発火します。
そのため、「你好」が3番目にコンソール画面に出力されます。
このように、非同期処理をコールバック関数のネスト構造にするによって、非同期処理の結果を待つことができます。
ですが、上のコードを見て「うわっ、これ見やすいわー」って思った人は一人もいないと思います。
コールバック関数のネスト構造は、処理が複雑化するにつれて可読性が悪くなるというデメリットがありました。
コールバック地獄を抜け出すために生まれたのが、タイトルにもあるPromiseです。
ちなみにPromiseオブジェクトはES2015に導入された標準組み込みオブジェクト(ユーザーが定義しなくても存在しているオブジェクト)です。
Promiseとはいったいなんなのか説明します。
以下MDNからの参照です。(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise)
Promise
インターフェイスは作成時点では分からなくてもよい値へのプロキシです。 Promise を用いることで、非同期アクションの成功や失敗に対するハンドラーを関連付けることができます。これにより、非同期メソッドは、最終的な値を返すのではなく、未来のある時点で値を持つ Promise を返すことで、同期メソッドと同じように値を返すことができるようになります。Promise
の状態は以下のいずれかとなります。
待機
pending
: 初期状態。成功も失敗もしていません。
満足
fulfilled
: 処理が成功して完了したことを意味します。
拒絶
rejected
: 処理が失敗したことを意味します。
待機状態のプロミスは、何らかの値を持つ満足 (fulfilled) 状態、もしくは何らかの理由 (エラー) を持つ拒絶 (rejected) 状態のいずれかに変わります。そのどちらとなっても、then
メソッドによって関連付けられたハンドラーが呼び出されます。
要約すると、Promiseは作成時にはどんな値が入ってるのかは確定していないものであり、非同期処理が終わればなんらかの値が入ってきます。Promiseオブジェクトを用いることで非同期処理に対して、成功時と失敗時の処理を作ることができますよってことが言いたいんだと思います。
また、Promiseには以下の三つの状態があります。
・Pending:待機状態
・Resolve(Fulfilled):非同期処理が成功した状態
・Reject(Rejected):非同期処理が失敗した状態
Promiseオブジェクトは、生成された直後から非同期処理が終了するまではPending状態になっています。
非同期処理が成功すれば、Resolve状態に移行しますし、失敗すればReject状態に移行します。
関係を以下に示しておきます。
.then
ではresolveされた値しか受け取れない?答えから言ってしまうとNoです。
というのも非同期処理の失敗時の処理を記述する.catch
が.then
のシンタックスシュガーだからです。
言ってる意味がわからないと思うので、.then
の本質についてお話しします。.then
は引数に二つのコールバック関数を持つことができます。1つ目は非同期処理の成功時(Resolve)のハンドリングに関するコールバック関数で、2つ目が非同期処理の失敗時(Reject)のハンドリングに関するコールバック関数です。
ですが、.catch
というシンタックスシュガーが生まれ、失敗時のハンドリングを明示的に分けられるようになったので、基本的に.then
では成功時のハンドリングを行うのだと考えてもらって結構です。
メソッドチェーンの例を以下に示しておきます。
const promise = new Promise(( resolve ) =>{
resolve("Resolve");
});
promise.then((value)=>{
console.log(value);
return "Resolve2"
})
.then((value)=>{
console.log(value);
return "Resolve3"
})
.then((value)=>{
console.log(value);
})
コンソール画面は以下のようになります。
挙動について説明します。.then
でreturn
した値は、Promise.resolve()
でラッピングされるという挙動があります。
これにより、あたかもPromiseオブジェクトのresolve()
した値を返すような処理にできます。そのため、次の.then
の引数にその値を受け取ることができるのです。
このようにして、.then
処理を繋げていくことを「メソッドチェーン」というので覚えておきましょう!
また、.then
で繋げて行う処理を直列処理というのでおさえておきましょう!
Promiseによってできることを以下の2点です。
・コールバック地獄を回避できる
・任意の非同期処理の完了を待って次の処理の評価に移行できる
Promiseについて結構理解してきたと思うので実際にPromiseオブジェクトを作ってみましょう!
以下のコードについて説明していきます。
const isResolved = true;
const promise = new Promise(( resolve , reject ) =>{
if (isResolved){
resolve("Resolve");
}
else{
reject(new Error("Reject"));
}
});
promise.then((value)=>{
console.log(value);
return "Resolve2"
})
.then((value)=>{
console.log(value);
})
.catch((value)=>{
console.error(value);
})
.finally(()=>{
console.log("finish")
});
コンソール画面への出力
このコードを知る上で必要な前提知識は「コンストラクタ関数、new
演算子」です。
これらについて知っている前提で説明するので、知らない人は以下の記事を読んでからきてください。
・コンストラクタ関数について https://unblog.unreact.jp/blog/jyu-mucim7kw
・プロトタイプチェーンについて https://unblog.unreact.jp/blog/xlw-4m3zf
まず、コードを見てわかるようにPromise
とは引数にresolve関数とreject関数を受け取るコンストラクタ関数なんです。new
演算子によって呼び出されることで、プロトタイプオブジェクトを元にインスタンス(Promiseオブジェクト)を生成しているということになります。
実際にPromiseオブジェクトをコンソール画面に出力してみると以下のようになっています。
なんと、お馴染みの__proto__にPromiseとありますね!!
そして、Promiseオブジェクトは、グローバルコンテキスト上で定義されているpromise変数にぶち込まれています。isResolved==true
なのでresolve()
によって、Pending状態からResolve状態に変更されます。
この時、resolve()
の引数に与えた値は.then
の引数に渡されます。
つまり、promise.then()
の中のvalue
には"Resolved"
という文字列が渡されているということになります。
そのため、コンソール画面にResolve
と表示されているのです。.then
でreturn
した値は、Promise.resolve()
でラッピングされるため、あたかもPromiseオブジェクトのresolve()
した値を返すような処理にできます。これにより、次の.then
の引数にその値を受け取ることができるのです。
そのため、2つ目の.then
の引数には、"Resolve2"が渡されており、コンソール画面にも2番目にResolve2
と出力されています。.catch
はPromiseオブジェクトの状態がRejectの時の処理をハンドリングするためのものなので、今回のケースだと飛ばされます。
そして、最後の.finally
はES2018から追加された機能です。必ずメソッドチェーンの最後に実行される関数です。引数は必要ないので受け取らなくても実行されます。
これにより、コンソール画面の3番目にfinish
と表示されます。
ここからは少し余談っぽい話になるので興味のある人は読んでください。
Promiseオブジェクトには2つのプロパティがあります。
そのうちの一つが、Promiseオブジェクトの3つの状態を示すPromiseStatusというプロパティです。
もう1つのプロパティはPromiseResultです。
この中には、resolve()
、reject()
の引数が入っています。
以下のコードによってPromiseのインスタンスを生成してみましょう。
const promise = new Promise((resolve,reject) => {})
コンソール画面は以下のようになりました。
先程説明した通り、インスタンス生成時にはpending状態になっています!
以下のコードのように、Promiseコンストラクタ関数の中でresolve()をすることでPromiseオブジェクトの非同期処理を成功させることができます。
const promise = new Promise((resolve,reject) => {
resolve("Promise is resolved")
})
.then((value)=>{console.log(value)})
console.log(promise)
コンソール画面は以下のようになりました。
fulfilledはresolve状態と考えてください。
今回の場合、.then
に戻り値がないためにPromiseResultはundefinedとなっています。
以下のコードによって、Promiseオブジェクトをreject状態にできます。
const promise = new Promise((resolve,reject) => {
reject()
})
console.log(promise)
コンソール画面を以下に示します。
rejectされてますね〜。
以下のコードを実行してみましょう。
const promise = new Promise((resolve,reject) => {
reject("Promise is rejected")
})
.catch((value)=>{console.log(value)})
console.log(promise)
結果は以下のようになります。
「PromiseStateがFulfilled?!」
「.catch
ってエラー時のハンドリングだからreject状態じゃないの?」
皆さんの心の声が聞こえてきます...。
実は、.catch
で処理で失敗時のハンドリングをした時点でPromiseはresolveされたんです。
これは僕の考察ですが、.catch
は.then
のシンタックスシュガーであり、戻り値がPromise.resolve()でラッピングされたためにPromiseStateはfulfilledになったのではないかと考えています。
Promiseには、先程紹介した直列処理のほかに、並列処理という機能もあります。
並列処理は、複数の非同期処理をまとめて処理したいというケースにおいて使われます。
そして、並列処理を行うためのメソッドは以下の二つがあります。
・Promise .all()
・Promise .race()
MDNによるpromise.all()の説明を以下に示します。
すべてのプロミスが解決されるか、拒否されるかするまで待ちます。
返却されたプロミスが解決された場合、解決されたプロミスが、複数のプロミスが含まれる iterable で定義された通りの順番で入った集合配列の値によって解決されます。
拒否された場合は、 iterable の中で拒否された最初のプロミスの理由によって拒否されます。
Promise .all()にはPromiseオブジェクトを配列形式で渡します。
そして、全てのPromiseオブジェクトの状態がresolveになれば次の処理に進むことができます。
Promise .all()を使ったコードを以下に示します。
const promise1 = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
}).then(() => {
console.log("1");
});
const promise2 = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 2000);
}).then(() => {
console.log("2");
});
//Promiseオブジェクトを配列形式で渡す
Promise.all([promise1, promise2]).then(() => {
console.log("All finish");
});
コンソール画面は以下のようになります。
MDNによるPromise .race()の説明を以下に示します。
Promise のうちの1つが解決または拒否されるまで待ちます。
返された Promise が解決された場合、 iterable の中で最初に解決された Promise の値によって解決されます。
拒否された場合、最初に拒否された Promise の理由によって拒否されます。
先程のPromise .all()と違って、複数のPromiseオブジェクトのうち1つでも状態がresolveになれば次に処理に移るようです。
こちらもコードを載せておきます。
const promise1 = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
}).then(() => {
console.log("1");
});
const promise2 = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 2000);
}).then(() => {
console.log("2");
});
Promise.race([promise1, promise2]).then(() => {
console.log("One of the processings finish");
});
コンソール画面を以下に示します。
確かに、一つ処理が終わった瞬間に次の処理が走ってますね!
Promiseによってコールバック地獄は解決されましたね!めでたしめでたし(ためにしためにし)と言いたいところですが。
本当にめでたく終われますか?.then
によってある程度可読性が上がりましたが、コールバック関数はいまだに.then
の引数に残ってるし、メソッドチェーンになると見にくいですよね。
そこで現れたのがasync / await(エイシンク・アウェイト)です。
この構文を利用することでPromiseを利用した構文よりも簡潔に書くことが可能になります。
asyncとはなんなのでしょうか?
こういう時はMDNを見ましょう!(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function)
sync function
宣言は、 非同期関数 —AsyncFunction
オブジェクトである関数を定義します。非同期関数はイベントループを介して他のコードとは別に実行され、結果として暗黙のPromise
を返します。ただし、非同期関数を使用したコードの構文および構造は、通常の同期関数と似たものになります。
要約すると、async functionは結果にPromiseをオブジェクトを返します。そして、嬉しいことに非同期関数を使用したコードの構文と構造が同期関数と似たように書けます!!
これにより、世界中のエンジニアのDXは著しく向上したことでしょう!!
イメージを持って欲しいので以下のasyncを用いたプログラムを使って解説します。
//async関数の挙動
async function resolveFunction() {
return 'resolve';
}
resolveFunction().then(value => {
console.log(value); // resolve
});
async function rejectFuncion() {
throw new Error('reject');
}
rejectSample().catch(err => {
console.log(err); //reject
});
// 通常の関数の挙動
function normalFunction() {
return 'normal';
}
// normalFunctionはPromiseを返さないので、エラーが発生して動かない
resolveError().then(value => {
console.log(value);//エラーはく
});
asyncの中でreturn
するとPromiseオブジェクトをresolve状態にして返します。
そのため、.then
でasync関数の戻り値を受け取ることができるというわけです。
asyncとセットで使われるのがこのawaitです。
MDNの説明を以下に示します。(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await)
await
演算子は、async function
によってPromise
が返されるのを待機するために使用します。await
式はasync
function の実行を一時停止し、Promise
の解決または拒否を待ちます。解決した後にasync
function の実行を再開します。再開するときにawait
式は解決されたPromise
にラップされた値を返します。Promise
が拒否された場合、await
式は理由となった値を投げます。await
式に続く値がPromise
ではなかった場合、解決された Promise に変換されます。
await演算子には以下に示す3つの特徴があります。
・async functionの中で使用する演算子(async functionの外では使えない)
・awaitが指定された関数はPromiseの結果が返ってくる(resolveもしくはrejectされる)までasync functionの処理を一時停止する
・結果が返されたらasync functionの処理を再開する
こちらもコードでイメージを掴みましょう。
async function resolveFunction() {
return 'resolve';
}
async function sample() {
const result = await resolveFunction();//ここでまつ
// resolveFunction()のPromiseの結果が返ってくるまで以下は実行されない
console.log(result);
}
上のコードを見てわかるように、.then
が消えました。
つまり、awaitは.then
のシンタックスシュガーであることがわかります。
今まで、.then
で受け取っていたPromiseResultをawait構文の左辺に定義した変数で受け取ることができます。
これによって、かなり直感的な記述が可能になりました。
ここまで読んでいただきありがとうございました。
Promise自体かなり難しい概念なので説明が難しいんですが、多くの文献を読んでさらに理解を深めていただけると良いと思います。
約束の時間に遅れそうなのでこの辺で終わりにします。
ではサラバッ!
・https://qiita.com/cheez921/items/41b744e4e002b966391a
・https://qiita.com/soarflat/items/1a9613e023200bbebcb3
・https://qiita.com/kiyodori/items/da434d169755cbb20447
・https://numb86-tech.hatenablog.com/entry/2017/01/02/152517
・https://qiita.com/kerupani129/items/2619316d6ba0ccd7be6a#31-async-%E9%96%A2%E6%95%B0
・https://qiita.com/ysk_1031/items/888a84cb259cec4e0625
・https://jsprimer.net/basic/async/
・https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise
・大岡由佳(2020.12.26)『リアクト!TypeScriptで始めるつらくないReact開発 第3.1版【Ⅰ . 言語・環境編】』pp141-147 くるみ割り書房
@ 古曳 純基
.then
ではresolveされた値しか受け取れない?