【フロントエンドの登竜門】非同期処理について

2022/05/25に公開

フロントエンド初心者が間違いなくぶつかる壁、非同期処理。
自分自身も業務で使用する機会が多々ありますが、フロントエンド初心者にも分かりやすく教えれるほど理解できていないと思いました。
今回は、非同期処理について詳しく調べてみましたので、非同期処理とはどういうものか初心者に分かりやすく伝えることを焦点において記事を書いてみようと思います。

非同期処理とは

非同期処理の特徴は、大きく分けて以下2つです。

  • 時間がかかる処理であること
  • ノンブロッキングな処理であること

まず、時間がかかる処理であることについてですが、例としてWebサイトの画面表示で説明してみましょう。ある画面を表示させようとURLを打ち込んだ後、フロント側はサーバー側に対してデータをリクエストします。それに対してサーバー側はフロント側にレスポンスを返し、画面を表示させます。フロント側とサーバー側は物理的に距離があり、通信に時間がかかるのは必然と言えますね。このように、JavaScript(TypeScript)上で時間のかかる処理は基本的に非同期処理を用いて行われます。

次にノンブロッキングな処理であることについてですが、あるタスクを実行している最中に、そのタスクの処理を止めることなく別のタスクを対応していることを指します。先ほど例に挙げたWebサイトの画面表示処理を以下に表してみました。データ取得処理を行なっている間にフロント側では別タスクを対応しています。本来の同期処理であれば、タスクを順番に処理していくので、あるタスクを行なっている最中に別のタスクを行うなんてことはできないわけです。

非同期処理のイメージはなんとなく掴めましたでしょうか。
次に具体的な非同期処理の方法を見ていきましょう。

コールバック関数

まず、コールバック関数についてですが、ある関数の引数に渡される関数のことです。
こちらは、非同期処理として扱われます。

コールバック関数の代表例としては、setTimeout関数があります。こちらを用いて今回はコールバック関数における非同期処理の挙動を調べてみましょう。

console.log(1)

setTimeout(() => {
  console.log(2)
}, 3000)

console.log(3)

setTimeout関数は第1引数に関数、第2引数に数値を設定します。数値はタイマーが起動するまでの時間(ミリ秒)として解釈され、今回の関数では3秒後に第1引数の関数が実行される挙動となります。

では、本題に戻ります。
こちらのプログラムに3つのconsole.log()が含まれていますが、どのような順番で実行されるでしょうか。

答えは、①→③→②です。
こちらの挙動を説明しますと、まずプログラムは上から順に処理されますので、はじめに1が表示されるのは納得できますね。次にsetTimeout関数の実行に差しかかりますが、こちらは非同期処理となっていますので、裏でプログラムを実行している間に次に進むことができます。次に最後のconsole.logまで到達して3が表示され、一旦プログラムは停止します。3秒後にコールバック関数が呼び出されて2が表示されます。

先ほど、非同期処理の特徴で紹介したノンブロッキングな処理が見事に実現されていますね。
コールバック関数での非同期処理の流れは、なんとなく掴むことができたでしょうか。

次は、非同期処理をより便利に扱うことができるPromiseについてみていきましょう。

Promise

Promiseとは、非同期処理の結果を保持するためのオブジェクトです。
Promiseでできることは多岐に渡り、全て記載するとそれだけで1つの記事を書くことができますので、今回はPromiseでの非同期処理の流れに焦点を当てて解説していこうと思います。
先ほど紹介したコールバック関数のプログラムをPromiseに書き直すと以下になります。

console.log(1)

const sleep = new Promise<void>((resolve) => {
    setTimeout(resolve, 3000)
})

sleep.then(() => {
  console.log(2)
})

console.log(3)

先ほど紹介したコールバック関数のプログラムよりは少し複雑になりましたね。
まず、new Promise()の部分についてですが、こちらの構文でPromiseオブジェクトを作成することができます。また、今回のPromiseコンストラクタでは、<void>と結果の型が指定され、引数resolveを持ちます。引数resolveはPromiseが持つ内部的な関数で、Promiseオブジェクトがはじめこそ未解決の状態になっていますが、関数resolveが呼び出されることによってPromiseの解決が起こります。

次に、then()の部分ですが、Promiseの解決が行なわれた時に登録されているコールバック関数を呼び出します。

少し説明が難しいですね。。。
実際にプログラムの挙動を追って理解していきましょう。

まずプログラムは上から順に処理していきますので、はじめに1が表示されます。次にsleep =の部分に差しかかり、未解決のPromiseオブジェクトを作成して変数sleepに代入します。その後、sleep.then()の部分の差しかかりますが、Promiseの解決は非同期的に行われますので、裏でプログラムを実行している間に次に進むことができます。次に最後のconsole.logまで到達して3が表示され、同期的な処理は停止します。同期的にやることがなくなったので、Promiseの解決処理に移行します。3秒後にPromiseが解決されてthen()で登録されているコールバック関数を呼び出して2が表示されます。

先ほど紹介したものと同じような流れになっていますが、処理自体には明確な違いがあります。
コールバック関数における非同期処理は、非同期処理を行う関数setTimeoutに直接コールバック関数を渡しています。一方、Promiseにおける非同期処理は、「非同期処理を行う関数setTimeoutはPromiseオブジェクトを返す」「返されたPromiseオブジェクトにthenでコールバック関数を渡す」と2段階の処理に分離しています。

なぜわざわざ非同期処理を2段階に分割したのかと疑問を持つ方もおられると思うのですが、コールバック関数を直接渡す方式では、使いたいAPIごとによってどのようにコールバック関数を渡せばいいか形が変わってきます。一方、PromiseベースのAPIでは、どんな関数でも「Promiseオブジェクトを返す」「Promiseを解決する」という共通認識さえあれば利用できるメリットがあります。

最後にasync/awaitにおける非同期処理についてみていきましょう。

async/await

async関数

まずasync関数から説明していきます。async関数は、返り値に必ずPromiseオブジェクトを返却する関数です。早速ですがasync関数を用いた例についてみていきましょう。

console.log(1);

async function getNum() {
  console.log(2);
  return 3;
}

getNum().then((num) => {
  console.log(num);
});

console.log(4);

こちらのプログラムに4つのconsole.log()が含まれていますが、どのような順番で実行されるでしょうか。

答えは、①→②→④→③になります。
こちらの挙動を説明しますと、まずプログラムは上から順に処理されますので、はじめに1が表示されるのは納得できますね。次に、getNum()の部分に差しかかり、getNum関数が呼び出されます。getNum関数は、async関数になりますが、その中身の実行は同期的に実行されます。よって、console.log(2)の部分に差しかかり、2が表示されます。次のreturn 3;によってgetNum()関数の実行が停止されます。getNum関数は、async関数なのでPromiseが返り値として返されます。Promiseの結果は3と決まっていますが、Promiseの解決は非同期的に行われますので、裏でプログラムを実行している間に次に進むことができます。
次にconsole.log(4)に差しかかり、4が表示され、同期的な処理が終了します。同期的にやることがなくなったので、Promiseの解決処理に移行します。Promiseが解決されてthen()で登録されているコールバック関数を呼び出して3が表示されます。

処理の流れは、Promiseと同じですがPromiseよりも分かりやすく記述することができます。
次に、await式含めた例をみていきましょう。

await式

await式はasync関数内でのみ利用することができます。そして、Promiseの結果が返ってくるまで待機するような特徴があり、thenメソッドのようにPromiseを解決する特徴も備えています。では、早速await式を利用した例をみていきましょう。

console.log(1);

const sleep = new Promise<void>((resolve) => {
  setTimeout(resolve, 3000);
});

async function getNum() {
  console.log(2);
  await sleep;
  console.log(3);
  return 4;
}

getNum().then((num) => {
  console.log(num);
});

console.log(5);

こちらのプログラムに5つのconsole.log()が含まれていますが、どのような順番で実行されるでしょうか。

答えは、①→②→⑤→③→④になります。
こちらの挙動を説明しますと、まずプログラムは上から順に処理されますので、はじめに1が表示されるのは納得できますね。次にgetNum関数が呼び出され、中身の実行に移ります。まずconsole.log(2)に差しかかり、2が表示されます。次にawait式のsleepに差しかかり、await式であることを確かめた後、getNum関数の実行自体を中断します。実行途中ですが、getNum関数の返り値として未解決のPromiseが渡されます。Promiseの解決は非同期的に行われますので、裏でプログラムを実行している間に次に進むことができます。次にconsole.log(5)に差しかかり、5が表示されて同期的な処理が終了します。
同期的にやることがなくなったので、中断したgetNum関数のawait sleepから実行が再開されます。先ほどawaitは、Promiseの結果が返ってくるまで待機されると紹介しましたね。今回はsleepの返り値のPromiseがawaitに渡され、3秒後にPromiseが解決されて次に進みます。次にconsole.log(3)に差しかかり、3が表示されます。そして、return 4によってgetNum関数の実行が停止されます。getNum関数は、async関数なのでPromiseが返り値として返されます。最後にthenによってPromiseが解決され、4が表示されます。

async/awaitを利用する上で、以下が優秀だと思いました。

  • コード量が少なくて済む
  • 同期的に非同期処理がかける

参考文献

Discussion