💭

今日こそJavaScriptの非同期処理とPromiseを理解しよう!

2021/05/13に公開約8,100字

概要

なんとなーく理解してなんとなーく書いている非同期処理とPromiseを今日こそ整理したいと思います。また、async/awaitの理解への導線としても非常に重要なので整理しておきたいです。いつものように、サンプルコードはなるべく余計な処理を書かずに、いきなり難しいことをせずにスモールステップで行きます。

あちこちで言われていることですが、async/awaitの理解にはPromiseが、Promiseの理解には非同期処理の理解が、非同期処理の理解にはコールバック関数の理解がほぼ必須です。実際にasync/awaitで書くとしても、いきなりasync/awaitを理解しようとするのは難しいと思います。

しかし製品同士の連携が重要になってきている今、kintone、servicenow、salesforce、slackなどレイヤーは違えどAPIをコールして連携させながら挙動をカスタマイズするのは当たり前の時代です。だからこそ、非同期処理の考え方の基礎を理解しておく必要があります。

そもそも同期/非同期とは

プログラムの基本は同期処理と言ってしまっても過言ではないでしょう。多くの言語や環境においては、同期処理を基本とします。

たとえば、以下のようなコードがあったとします。

sample.js
let i = 0;
console.log(i): //0が出力される
i += 1;
console.log(i); //1が出力される

コメントにて記載の通り、当然ながら0が出力された後に1が出力されるはずです。
つまり、前の処理が終わることを待ってから(処理同士が同期しながら)進んでいく処理のことを同期処理と呼びます。

非同期はこの逆です。つまり、前の処理が終わらなくても先へ進む(処理同士が同期しない)ことを非同期処理と呼んでいると考えることができます。上記のコードはこのままでは非同期的に動くことはありません。**非同期処理を行うには"何か特殊なことをしないといけない"**のです。

ここまでをまとめると以下のようになります。

  • プログラムの基本は同期処理
  • 非同期処理をするにはなにか特殊なことをしないといけない

(少しだけ寄り道)非同期処理が適しているケースとは?

以前のコードのような、ちょっとした計算処理程度であれば非同期処理にするまでもありません。通常は時間がかかる処理を非同期的に行うと全体の時間を縮めることができ、プログラム全体の実行速度が上がります。

現実的なところで言うと以下のようなケースが該当します。これらのケースは通常の計算処理とは比較にならないほどの時間が必要なため、非同期的に動かしても問題ない処理を非同期処理にしてあげることで、コード全体の最終的な実行時間を縮めることができる可能性があります。

  • 通信を伴う処理(特にAPIのコールなど)
  • ファイルの読み書き

"特殊なこと"とは?

では、非同期処理を実現するための"特殊なこと"とはなんでしょうか?
JavaScriptにおいては、Promiseを利用することです。

Promise(約束)とはなにか?

Aさん、Bさんが一つの大きなプロジェクトに取り組んでいるとします。プロジェクトの達成には、2つの大きな作業があって同時並行で問題ありません。この場合、このような会話が発生することでしょう。

Aさん:Bさん、これやっといてくれないか?
Bさん:OK、終わったら連絡するね。なにかわからなかったら聞くからよろしく。 ←ここ重要!
Aさん:よろしくー。

~しばらく後~
Bさん:Aさん、終わったよ!
Aさん:こっちも終わったから合体させようか。

「ここ重要!」部分のBさんのセリフに着目してください。当たり前のような会話ですが、3つの約束事が含まれています。

  • お願いした処理をやってくれるという約束
  • 処理が終わったら連絡してくれるという約束
  • なにかわからないことがあった(処理がうまくいかなかった)ら教えてくれるという約束

AさんがBさんの処理を終わるのを待つのが同期処理なのに対し、AさんBさんが処理を並行して進めるのが非同期処理ですよね。
ということは、何らかの約束がないと怖いわけです。そこで、あのようなセリフを言うことでこれらの約束を暗黙的に行っていると言えます。

つまり、Bさんは依頼を受けた時点で成果物を返すのではなく(これだと成果が出るのを待たないといけない)、約束をすることによって非同期処理を実現していると言えるでしょう。

JavaScriptにおけるPromise(約束)

JavaScriptにおいて、約束をしてくれる関数は多数存在します(自作するというよりもこういう関数を使うことができるようにPromiseの理解が必要です)。

例として、fetch関数を使います。fetch関数はAPIを叩くときによく使われる関数ですが、細かいことは気にせず、時間がかかる処理と思ってください。

前者を普通に使うと以下のようになります。

fetch関数を普通に実行したときの例
const result = fetch("http://api.example/users");

おそらくAPIの結果が戻り値として返ってくるのだろうと予測できるでしょう。しかし、そうではありません。

一部省略していますが、以下のようになっています。fetchの戻り値は**APIコールの結果ではなく、約束(Promise)**なのです。

fetch関数の戻り値を見てみる
Promise {<pending>}
  __proto__: Promise
    catch: ƒ catch()
    constructor: ƒ Promise()
    finally: ƒ finally()
    then: ƒ then()
    Symbol(Symbol.toStringTag): "Promise"
    __proto__: Object
    [[PromiseState]]: "pending"
    [[PromiseResult]]: undefined

JavaScriptのPromiseは大きく3つの情報が含まれています。AさんBさんとほぼ一緒ですね。

  • お願いした処理をやってくれるという約束
  • 処理が終わったら、予め指定された処理(resolveと言います)をやってくれるという約束
  • 処理がうまくいかなかったら、予め指定された処理(rejectと言います)をやってくれるという約束

まとめると

  • 非同期処理とは、結果を待つのではなく約束をしてもらうことによって、信用して先の処理へ進むこと
  • 通信を含む処理など(特にfetch)を非同期的に動かすことによってプログラム全体の効率が上がる可能性が高い
  • JavaScriptのPromiseには大まかに3つの約束事が含まれる

resolve、rejectを活用する

JavaScriptのPromiseに含まれる3つの約束事を整理します。

約束事 実現方法
お願いした処理をやってくれるという約束 fetch()の引数にURLを渡す
処理が終わったら、予め指定された処理をやってくれるという約束 Promiseのオブジェクトに対してthen()メソッドを呼び出し、関数を渡す
処理がうまくいかなかったら、予め指定された処理をやってくれるという約束 Promiseのオブジェクトに対してcatch()メソッドを呼び出し、関数を渡す

コールバック関数

関数の引数に関数を渡すという概念がでてきます。これをコールバック関数と言います。たとえるなら、AさんがBさんに仕事を依頼するという関数を呼び出し、引数として作業内容(これも◯◯を行う、という意味では関数ですよね)を与えるようなものです。

コールバック関数の基本
function B_san(todo){
  todo();
}

function work1(){
  console.log("あれやって");
  console.log("これやる");
}

//ここがメインの処理
B_san(work1);

関数の引数に関数を渡す、とおぼえておくと良いでしょう。
利点がわかりにくいかもしれませんが、JavaScriptが生まれた背景であるWebの世界ではこのような処理がたくさん出てきます。たとえば「送信ボタンが押されたら◯◯したい」のように、「いつ呼び出されるかは決まっているけど何をするかは決まっていない」、そういうときにコールバック関数を引数として受け取るような作りになっているのです。

これもちなみにですが、関数を引数として受け取る関数のことを高階関数と言います(関数を戻り値として返す関数も高階関数と言います)。

Promiseオブジェクトができること

fetch関数の例に戻ります。

const result = fetch("http://api.example/users");

このresultは、Promiseオブジェクトであることがわかっています。Promiseオブジェクトには主に以下のようなメソッドがあり、呼び出すことができます。

  • .then()
  • .catch()

then

thenは、Promiseの結果がうまくいった場合の処理です。引数に関数を渡すことができます。コールバック関数ですね。
このように書くことができます。

const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

function showSuccess(){ //うまく行ったときの処理を関数として定義しておく
  console.log("うまくいったみたい!");
}

result.then(showSuccess); //予め定義しておいた関数をthenの引数に渡す
console.log("プログラム終了");

これで、APIコール時に約束が生成され、約束が果たされたときの挙動がshowSuccess関数であるという定義になります。したがって、showSuccess関数は、約束が果たされたときに動くことになります。
つまり、APIのコールにある程度時間がかかるのであれば、上記コードの結果は以下のようになるはずです。順序がひっくり返っているところに着目してください。これが非同期的に動いているということになります。

プログラム終了
うまくいったみたい!

以下しばらく補足が続きます。

関数を値として利用する書き方(知っている場合は飛ばしてOK!)

関数の引数に関数を渡す(関数を一つの値として渡す)場合は、()をつけません。()をつけてしまうと、「関数を呼び出したときの戻り値」という意味になってしまいます。そうではなく、関数そのものを表現したいのでこのように書きます。

const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

function showSuccess(){ //うまく行ったときの処理を関数として定義しておく
  console.log("うまくいったみたい!");
}

result.then(showSuccess); //← result.then(showSuccess())じゃないよ!!!
console.log("プログラム終了");

言語によって、関数をこのように扱うことができるかどうか異なります。このような扱いができることを、「関数が第一級オブジェクトである」と言います。

アロー関数の書き方(知っている場合ry)

最近のJavaScriptでは(正確にはES6)、関数定義を以下のように書くことがあります。

const hoge = () => { //アロー関数を使った定義
  console.log("処理");
}

基本的には、以下と同じですがthis関連で大きな違いがあります。記事が長くなってしまうのでここでは触れません。

function hoge(){
  console.log("処理");
}

また、アローは一時的な名前無しの関数(無名関数)を作る場合に頻繁に目にするので、慣れておくと良いでしょう。特に関数の引数に関数を渡すようなシチュエーションです。先のfetch関数ような場合ということですね。

基本的な書き方
const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

function showSuccess(){ //うまく行ったときの処理を関数として定義しておく
  console.log("うまくいったみたい!");
}

result.then(showSuccess); //予め定義しておいた関数をthenの引数に渡す
console.log("プログラム終了");
無名関数として定義する場合
const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

result.then(() => {
  console.log("うまくいったみたい!");
}); //このタイミングで一時的な関数を作り、thenの引数に渡す

console.log("プログラム終了");

()は、今定義している関数の引数部分に当たります。=> {}は、この関数の処理部分です。中括弧の中は、通常の関数と同じように処理を書いて構いません。

また、引数が1つの場合は()を省略しても構いません。

引数が一つしかない場合
result.then( value => {
  console.log("うまくいったみたい!");
});

処理が1行しかない場合は、{}も省略できます。中括弧がなくなるので、"関数に処理(実態は関数)を渡している感"が出ますし、コードもスッキリします。

引数が一つしかない場合
result.then(value => console.log("うまくいったみたい!"));

用語がややこしいですが、名前をつけずに関数を定義するのが無名関数、アロー関数が関数定義の別の書き方なので上記の例だと「無名関数を、アロー関数の書き方で書いている」と表現するのが正しいですかね...。

それでは、本題の話に戻ります。

catch

catchでは、約束事が失敗したときの挙動を定義することができます。ここからはアロー関数+即時関数の書き方を使います。
以下のようになります。やり方はthenのときと同じですね。

const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

result.catch(() => {
  console.log("ダメだったみたい");
}); //ダメだったときの定義

console.log("プログラム終了");

メソッドチェーン

then、catchはつなげて書くことができます。
今回のresultがPromiseオブジェクトなのは既にわかっていますが、thenやcatchの戻り値もまたPromiseオブジェクトです。なので、このように書くことができます。

メソッドチェーンを利用した書き方
const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

result.then(() => console.log("うまくいったよ!")).catch(() => console.log("ダメだったよ!"));

console.log("プログラム終了");

改行など入れると見やすいです。

メソッドチェーンを利用した書き方
const result = fetch("http://api.example/users"); //resultはPromiseオブジェクト

result
.then(() => console.log("うまくいったよ!"))
.catch(() => console.log("ダメだったみたい"));

console.log("プログラム終了");

また、thenもcatchも戻り値が別のPromiseオブジェクトであることから、thenの呼び出しの後にthenを更に連ねていくといった書き方も可能です。なんだか、try-catchに似ていますね。

処理と順序をざっくりを整理すると

Aさん(プログラム本体)の処理 Bさん(fetch)の処理
fetchを呼び出す
Promise(約束)を返す
うまく行ったとき、行かなかったときの処理を指示する 約束を実行しはじめる
約束を信用して先の処理へ進み、プログラム終了と表示する
約束が終わったら、予め指示された関数を動かす

このように、非同期的に動いていることになります。

ここまでまとめ

  • Promiseオブジェクトに対してthenやcatchのメソッドを呼び出すことで、約束が果たされたときと果たされなかったときの挙動を定義できる
  • 挙動は関数で渡す
  • メソッドチェーンも使えるよ

async/await

async/awaitはPromiseの一部のシチュエーションを楽に書けるようにしたシンタックスシュガー(糖衣構文)です。シンタックスシュガーであるということは、どちらで書いても結果は同じということなので、Promiseで書いても別に問題はありません。ただ、一部のシチュエーションではasync/awaitのほうが圧倒的に楽というか、シンプルにかけます。記事が長くなりすぎるので、また別記事で書きます。

全体のまとめ

  • 非同期処理の本質は「処理待ち」を「約束」に置き換えること
  • 約束があるなら、信用して先へ進んで良い(これが非同期処理)
  • ただし、約束するなら「うまく行ったときの処理」と「うまく行かなかったときの処理」を指示しておく必要がある

Discussion

ログインするとコメントできます