【JavaScript学び直しガイド】非同期処理とasync/awaitの解説

前回の内容をハンズオンぽくしてみました。こういうのをまとめるの難しいですね🙄


1. はじめに:非同期処理とは

なぜ非同期処理が必要なのか

Webアプリケーションを開発していると、時間のかかる処理に多く直面します。例えば、サーバーからデータを取得したり、大きなファイルを処理したり、複雑な計算を行ったりする場合です。

JavaScriptは、シングルスレッドで動作しているため、一度に一つの処理しか実行できません。もし時間のかかる処理を普通に(同期的に)実行すると、その処理が完了するまでブラウザ(JavaScript処理系)は他の操作を受け付けられなくなります。そうなった場合には、ユーザーがボタンをクリックしても反応せず、スクロールもできず、画面が「フリーズ」したように見えてしまいます。これは明らかに良い作りではありません。

非同期処理はこの問題を解決する手段です。時間のかかる処理をバックグラウンドで実行させながら、メインの処理(ユーザーインターフェースの応答など)を妨げないようにする仕組みで、処理完了後、その結果を受け取って続きの処理を行うことになります。

日常生活での例:喫茶店

日常生活で例えるなら、喫茶店での注文プロセスに似ています。

同期処理(悪い例)

  1. お客さんはコーヒーを注文する
  2. 店員がコーヒーを入れ始める
  3. お客さんははカウンターの前に立ち、じっとコーヒーができるのを待つ
  4. この間、他のお客さんは何もできない(サービスが止まる)
  5. コーヒーができたら受け取り、やっと次のお客さんが注文できる

非同期処理(良い例)

  1. お客さんはコーヒーを注文し、注文番号を受け取る
  2. 店員がコーヒーを入れ始める
  3. お客さんはテーブルに座り、本を読んだり友達と話したりできる
  4. 他のお客さんも同時に注文できる
  5. コーヒーができたら名前や番号で呼ばれ、受け取りに行く

非同期処理JavaScriptのシングルスレッド環境で複数のタスクを効率的に処理するための仕組みです。特にWebアプリケーションでは、ユーザーインターフェースの応答性を維持しながら、バックグラウンドでデータを取得したり処理したりする必要があるので非同期処理が必要とされます。

非同期処理の進化

JavaScriptでの非同期処理は時間と共に進化してきました。

  1. コールバック関数 … 最も初期の方法ですが、複雑な処理ではコールバック地獄と呼ばれる読みにくく管理しづらいコードになりやすい問題がありました。

  2. Promise … コールバック地獄を解決するために導入され、非同期処理をより構造化された方法で扱えるようになりました。しかし、複雑な処理では依然としてコードが読みにくくなる課題がありました。

  3. async/await … 最新の方法で、Promiseをベースにしながらも、非同期コードをまるで同期コードのように書けるようになり、可読性とメンテナンス性が大幅に向上しました。

今回は、async/await構文を中心に、その背景にある非同期処理Promiseの基本概念も理解していきます。

2. なぜasync/awaitが生まれたのか

コールバック地獄の問題

JavaScriptでの非同期処理は当初、コールバック関数で処理されていました。

コールバック関数とは、別の関数に引数として渡され、その関数の処理が完了した後(または特定のタイミングで)実行される関数です。つまり、後で呼び出される関数となります。

// 基本的なコールバックの例
function doSomething(callback) {
  console.log("処理を開始します");
  // 何らかの処理
  
  // 処理が終わったらコールバックを実行
  callback();
}

// 関数を呼び出し、コールバック関数を渡す
doSomething(function() {
  console.log("処理が完了しました");
});

よく使われる例としては、setTimeOut()があります。

# setTimeout()の例
console.log("処理を開始します");

// 2秒後にコールバック関数が実行される
setTimeout(function() {
  console.log("2秒経過しました");
}, 2000);

console.log("setTimeout後の処理(待たずに実行されます)");

先ほどの例は簡単なものでしたが、複雑になってくると以下のような記述になっていきます。

// コールバックを使った非同期処理
getData(function(data) {
  processData(data, function(processedData) {
    saveData(processedData, function(result) {
      displayResult(result, function() {
        console.log('完了');
      }, handleError);
    }, handleError);
  }, handleError);
}, handleError);

このような記述では以下のような問題があります。

  • インデントが深くなり、コードが右に寄っていく(コールバック地獄
  • エラー処理が各レベルで重複
  • 処理の流れが追いづらい
  • 条件分岐や繰り返しが複雑になる

このような問題からコールバック地獄を防ぐ手法が作られていきました。

Promiseによる改善と限界

そんな中、Promiseが生まれました。Promise将来の結果を約束するJavaScriptのオブジェクトです。

このPromiseオブジェクトのもつ.then()メソッドを使って、複数の非同期処理を順番に実行していくことで非同期処理のわかりにくさを改善しています。これにより、各Promiseが完了したら次のPromiseを開始するという流れになっています。

// Promiseチェーンを使った非同期処理
getData()
  .then(data => processData(data))
  .then(processedData => saveData(processedData))
  .then(result => displayResult(result))
  .then(() => console.log('完了'))
  .catch(error => handleError(error));

これによりコールバックの記述より以下の改善点がありました。

  • 平坦な構造になり読みやすくなった
  • エラー処理が一元化された
  • メソッドチェーンで流れが見やすくなった

ただ、それでも残る問題もあります。

  • 長いPromiseチェーンは読みにくい
  • 条件分岐が必要な場合、コードが複雑になる
  • デバッグが難しい(スタックトレースが分かりにくい)
  • try-catchのような標準的なエラー処理ができない
  • 既存の同期コードと非同期コードで書き方が大きく異なる

async/awaitによる解決

async/awaitはこれらの問題を解決する仕組み(構文糖:シンタックスシュガー)として登場しました。 では、先程のようなコードはasync/awaitを使用すると以下のように記述できます。

// async/awaitを使った非同期処理
async function processAll() {
  try {
    const data = await getData();
    const processedData = await processData(data);
    const result = await saveData(processedData);
    await displayResult(result);
    console.log('完了');
  } catch (error) {
    handleError(error);
  }
}

どうでしょうか?同期的なコードとほぼ変わらないように見えたのではないでしょうか? async/awaitによって以下の点が改善されています。

  • 同期コードと同じような見た目と流れ
  • 標準的なtry-catchでエラー処理
  • 条件分岐や繰り返しが自然に書ける
  • 変数スコープが明確
  • デバッグしやすい(スタックトレースが追いやすい)
  • コードの意図が明確になる

async/awaitは内部的にはPromiseを使っていますが、より自然で読みやすいコードを書けるようになりました。

3. シンプルな例から考えてみる

問題のあるコード(同期的に待つ)

以下のコードはビジーのウエイトを追加することで同期的に待機していますが、処理をし続けるため負荷がかかり良くないコードになっています。

function blockingDemo() {
    console.log("開始");
    const start = new Date().getTime();
    
    // CPUを占有する処理
    while (new Date().getTime() < start + 3000) {
      // 何もしない(ビジーウェイト)
    }
    
    console.log("3秒経過");
    return "完了";
  }
  
  console.log("処理を実行します...");
  const result = blockingDemo();
  console.log("結果:", result);
  console.log("この行は blockingDemo の完了後にしか表示されません");

実行結果は以下のように表示されます。

より良い方法(Promise + async/await

先ほどのコードをPromiseasync/awaitを使って書くと以下のようになるでしょうか。

// Promiseを使った遅延関数
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 非同期版のデモ関数
async function nonBlockingDemo() {
  console.log("開始");
  
  // 3秒待機(ノンブロッキング)
  await delay(3000);
  
  console.log("3秒経過");
  return "完了";
}

// メイン処理を非同期関数で囲む
async function main() {
  console.log("処理を実行します...");
  
  // await で結果を待つ
  const result = await nonBlockingDemo();
  
  console.log("結果:", result);
  console.log("この行は nonBlockingDemo の完了後に表示されます");
}

// 非同期関数を実行
main();

// この行はすぐに実行される
console.log("main()を呼び出した後、この行はすぐに表示されます");

この処理を実行するとmain()内の非同期処理の後続にあるmain()を呼び出した後、この行はすぐに表示されますが表示されている事がわかります。(ブロッキングされず次の処理へ進み、Promiseの処理完了後にその処理後の処理を実行している)

4. Promiseの基礎

Promiseとは?

繰り返しの説明となりますが、Promiseとは「将来の値を約束する」オブジェクトです。非同期処理の状態と結果を表現したものとも言えます。

3つの状態

Promiseには3つの状態があり、その状態を使用して非同期的処理を制御しています。

  • pending(保留中) … 初期状態、まだ結果が出ていない
  • fulfilled(成功) … 処理が成功して値を持っている
  • rejected(失敗) … 処理が失敗してエラーを持っている

シンプルな例

以下の例では成功の状態(乱数が0.5より大きい)の場合にresolveの値にを格納し、失敗の状態(乱数が0.5以下)にはrejectの値にを格納しています。

// コイン投げをシミュレート(50%で成功/失敗)
function flipCoin() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      Math.random() > 0.5
        ? resolve('表')   // 成功の状態として定義
        : reject('裏');   // 失敗の状態として定義
    }, 1000);
  });
}

// 使い方
flipCoin()
  .then(result => console.log(`成功: ${result}`))
  .catch(error => console.log(`失敗: ${error}`));

5. async/await の基本

ではasync/awaitを使用したコードはどのように書くかを見ていきます。

async関数の使い方

async(async関数)は以下のような記述をします。

async関数

async function myFunction() {
  // 非同期処理
}

// アロー関数でも可能
const myFunction = async () => {
  // 非同期処理
};

async関数には以下のような特徴があります。

  • 常にPromiseを返す
  • 内部でawaitキーワードが使える

await式の使い方

await(await式)は以下のような記述をします。

async function example() {
  try {
    // Promiseが解決するまで待機
    const result = await somePromiseReturningFunction();
    console.log(result); // Promiseの結果
  } catch (error) {
    // Promiseが拒否された場合
    console.error(error);
  }
}

await式の使用においては以下の点に注意してください。

  • awaitasync関数内でのみ使用可能
  • awaitはPromiseが完了するまで処理を一時停止する。(他の処理は行われる)
  • Promiseが拒否されるとエラーがthrowされる

6. エラーハンドリング

async/awaitを使用したエラーは、try/catchを使用してハンドリングできます。

基本パターン

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    
    if (!response.ok) {
      throw new Error(`HTTPエラー: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
    
  } catch (error) {
    console.error('データ取得エラー:', error.message);
    // エラー時の代替処理
    return { error: true, message: error.message };
  } finally {
    // 成功/失敗に関わらず実行
    console.log('処理完了');
  }
}

エラー処理の扱い方は以下のようになります。

  • try/catchで非同期処理のエラーが捕捉できる
  • エラー種別に応じた処理が可能
  • finallyブロックは、処理の成功・失敗に関わらず必ず実行することができます。(リソースの解放や後片付けに便利)

7. 応用例

非同期処理を複数の処理を行う場合には以下のようにコードを書くことができます。

複数ユーザーの取得(逐次実行)

以下の例では非同期処理を順番に処理しています。

async function fetchUsersSequentially(ids) {
  const users = [];
  
  for (const id of ids) {
    // 一人ずつ順番に取得
    const user = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then(response => response.json());
    users.push(user);
  }
  
  return users;
}

複数ユーザーの取得(並行実行)

以下の例では非同期処理を一気に処理開始し、並列に処理しています。

async function fetchUsersParallel(ids) {
  // すべてのリクエストを同時に開始
  const promises = ids.map(id => 
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then(response => response.json())
  );
  
  // すべての結果を待機
  const users = await Promise.all(promises);
  return users;
}

// 使用例
fetchUsersParallel([1, 2, 3])
  .then(users => {
    console.log('取得したユーザー:', users.map(u => u.name).join(', '));
  });

逐次処理と並列処理の例を用いるのであれば3つの1秒かかるAPIを呼び出す場合、逐次処理なら3秒かかるが、Promise.allを使えば約1秒で完了することが可能となります。

8. まとめ

今回のまとめですが、async/awaitの利点は以下の様になります。

  • 読みやすく直感的なコード
  • 標準的なエラーハンドリング(try/catch)
  • デバッグのしやすさ

ただ、注意点もあります。

  • await忘れに注意(最も多いミス-変数の中身が空のときはこれが原因)
  • async関数は常にPromiseを返す

これらの注意点に気をつけて使用してください。

おわりに

JavaScriptの非同期処理であるasync/awaitについて学んできました。非同期処理はモダンなWebアプリケーション開発において不可欠な要素です。時間のかかる処理をバックグラウンドで実行しながら、UIの応答性を維持することができます。async/awaitを使いこなせるようになれば、複雑な非同期処理も明確で読みやすいコードで表現できるようになるでしょう。