2021/06/29

【JavaScript】非同期処理といえばPromise!

@ 古曳 純基

JavaScriptReact

はじめに

どうもどうも、じゅんきです。
最近、Firebaseの学習に伴って「非同期処理」が頻繁に出てくるようになったので、今回は非同期処理の主役といっても過言ではないPromiseについて説明していくっ!

メンタルモデル

いつもながら、軽くメンタルモデルを作っときます。

1, 非同期処理とは複数の処理を同時に行う処理のことである
2, Promiseとは非同期処理の言語を同期的に扱いたいというユースケースで使うオブジェクトである
3, Promiseと.thenのシンタックスシュガーがasync / await構文である

この2点をとりあえず頭に入れておいてください。

同期処理と非同期処理のおはなし

同期処理ってなに?

そもそも同期処理とはなんでしょうか?
言葉は知ってても説明できる人は意外といないんじゃないかと思います。

前提として、基本的にプログラムというのは上から下に向かって順番に読み込まれていきます。
このとき、どんなに時間がかかる処理があっても先に進まずにその処理が終わるのを待ちます。
こう言った処理のことを同期処理と言います。
図にすると以下のようなイメージになります。


非同期処理ってなに?

同期処理について説明しましたが、これにはデメリットもあります。
それは、「サーバー側にクエリを飛ばしてデータを取ってくる処理」や「多大な計算を必要とする処理」など時間のかかる処理においても処理が終わるのを待つので、全体の処理が終わるのが遅くなるという点です。
JavaScriptでは、データのフェッチや計算の他にもブラウザの見た目に関する処理もあるので、時間のかかる処理をいちいち待っていると画面がフリーズしたように見え、UXを著しく下げるかもしれません。

そこで、時間のかかる処理を他の処理と並行して行うことで時間が短縮できるんじゃね?と考える人が現れました。
非同期処理では、同期処理のように上から下に向かって処理の評価をしていきますが、時間がかかる処理があれば、その処理の終了を待たずに次の処理の評価を始めます。
図にすると以下のようになります。



同期処理と非同期処理がどのようなものかある程度理解できたと思います。

コールバック地獄(Callback Hell)

非同期処理を行う上で、時間がかかる処理が終了してからでないと次の処理がができないというケースもあります。
例を挙げると、サーバーから画像データを取ってきてフロントで表示すると言ったケースが挙げられます。
こう言ったケースの非同期処理のハンドリングを解決するために、コールバック関数が使用されていました。
コールバック関数とは、関数の引数になっている関数のことでしたね!

高階関数(引数に関数を持つ関数)が実行されなければ、コールバック関数は実行されないという仕組みが非同期処理を同期的にハンドリングするのに非常に向いていました。
例を挙げてみましょう。以下にコードを示します。

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です。
ちなみにPromiseオブジェクトはES2015に導入された標準組み込みオブジェクト(ユーザーが定義しなくても存在しているオブジェクト)です。

Promiseとはなんなのか?

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);
})


コンソール画面は以下のようになります。


挙動について説明します。
.thenreturnした値は、Promise.resolve()でラッピングされるという挙動があります。
これにより、あたかもPromiseオブジェクトのresolve()した値を返すような処理にできます。そのため、次の.thenの引数にその値を受け取ることができるのです。

このようにして、.then処理を繋げていくことを「メソッドチェーン」というので覚えておきましょう!
また、.thenで繋げて行う処理を直列処理というのでおさえておきましょう!

Promiseになにができる?

Promiseによってできることを以下の2点です。
・コールバック地獄を回避できる
・任意の非同期処理の完了を待って次の処理の評価に移行できる

Promiseオブジェクトを実際に作ってみよう!!

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と表示されているのです。

.thenreturnした値は、Promise.resolve()でラッピングされるため、あたかもPromiseオブジェクトのresolve()した値を返すような処理にできます。これにより、次の.thenの引数にその値を受け取ることができるのです。
そのため、2つ目の.thenの引数には、"Resolve2"が渡されており、コンソール画面にも2番目にResolve2と出力されています。

.catchはPromiseオブジェクトの状態がRejectの時の処理をハンドリングするためのものなので、今回のケースだと飛ばされます。
そして、最後の.finallyはES2018から追加された機能です。必ずメソッドチェーンの最後に実行される関数です。引数は必要ないので受け取らなくても実行されます。
これにより、コンソール画面の3番目にfinishと表示されます。

PromiseStatusとPromiseResultとは?

ここからは少し余談っぽい話になるので興味のある人は読んでください。
Promiseオブジェクトには2つのプロパティがあります。
そのうちの一つが、Promiseオブジェクトの3つの状態を示すPromiseStatusというプロパティです。

もう1つのプロパティはPromiseResultです。
この中には、resolve()reject()の引数が入っています。

Promiseインスタンスを作成してみた

以下のコードによってPromiseのインスタンスを生成してみましょう。

const promise = new Promise((resolve,reject) => {})


コンソール画面は以下のようになりました。

先程説明した通り、インスタンス生成時にはpending状態になっています!

resolveしてみた

以下のコードのように、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となっています。

rejectしてみた

以下のコードによって、Promiseオブジェクトをreject状態にできます。

const promise = new Promise((resolve,reject) => {
  reject()
})
console.log(promise)


コンソール画面を以下に示します。

rejectされてますね〜。

rejectなのに状態はResolve(Fulfilled)?!

以下のコードを実行してみましょう。

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()

Promise .all()の紹介

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");
});


コンソール画面は以下のようになります。

Promise.race()の紹介

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");
});


コンソール画面を以下に示します。

確かに、一つ処理が終わった瞬間に次の処理が走ってますね!

async / awaitの登場

Promiseによってコールバック地獄は解決されましたね!めでたしめでたし(ためにしためにし)と言いたいところですが。
本当にめでたく終われますか?
.thenによってある程度可読性が上がりましたが、コールバック関数はいまだに.thenの引数に残ってるし、メソッドチェーンになると見にくいですよね。

そこで現れたのがasync / await(エイシンク・アウェイト)です。
この構文を利用することでPromiseを利用した構文よりも簡潔に書くことが可能になります。

async

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関数の戻り値を受け取ることができるというわけです。

await

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 くるみ割り書房

© 2021 powerd by UnReact