🥏

JavaScriptの非同期処理

2025/03/05に公開
3

非同期処理とは

時間のかかる処理(例:APIリクエストやファイル操作)の完了を待たずに、他の処理を進めることができる仕組み。

なぜ必要なのか

JavaScriptは基本的にシングルスレッドで動作する。
そのため、一つの処理が完了するまで次の処理を実行できず、同期的に処理をすると(非同期処理を使わないと)ブロッキングが発生する。
例えば、ブラウザの場合は次のような挙動が起こり、ユーザー体験の悪化につながる。

  • 大量のデータを読み込んで表示する際に、画面が真っ白なまま長時間待たされる。
  • 重い計算処理の際に、計算が終わるまでボタンのクリックや画面のスクロールができない。

同期的な処理の例

console.log("処理開始");
const start = Date.now();
while (Date.now() - start < 5000) {} // 時間のかかる処理(5秒間待機)
console.log("処理終了")

出力

処理開始
(5秒間フリーズし、その間画面操作ができない)
処理終了

非同期処理を使うことで、この問題を回避することができる。

非同期処理の歴史

コールバック関数

初期の非同期処理の実装は、コールバック関数を用いていた。
コールバック関数とは、ある関数に引数として渡され、その関数の実行が終わった後や特定のイベントが発生したときに呼び戻される(callback)関数のこと。

例(コールバック関数)

function fetchUserData(callback) {
    console.log("処理開始");
    setTimeout(() => {
        const user = { name: "花子", age: 30 };
        callback(user);
    }, 5000); // 時間のかかる処理(5秒間待機)
}
fetchUserData((user) => {
    console.log(`処理終了:こんにちは、${user.name}さん!`);
});

出力

処理開始
(5秒間待機している間、画面の操作が可能)
処理終了:こんにちは、花子さん!

デメリット

  • ネストが深くなり、可読性が低下する(コールバック地獄)

    // コールバック地獄の例
    getUserData(function(user) {
      fetchUserPosts(user, function(posts) {
        processPosts(posts, function(result) {
          // ... さらに続く
        });
      });
    });
    
  • エラーハンドリングが複雑

Promise

コールバック地獄を解消するために導入された。
非同期処理の結果を管理し、成功時(resolve)と失敗時(reject)の処理を分岐できる仕組み。

Promiseは、以下の3つの状態を持つ。
pending(保留中):非同期処理が進行中で、まだ結果が決定していない状態。
fulfilled(成功):非同期処理が正常に完了し、結果が得られた状態。
rejected(失敗):非同期処理がエラーで失敗した状態。

Promiseの利用時には、以下のメソッドを利用して、成功・失敗時のコールバック関数を渡す。
.then():非同期処理の成功時の処理を記述
.catch():非同期処理でエラーが発生したときの処理を記述
finally() :成功・失敗に関係なく、最後に必ず実行される処理を記述

例(Promise)

const fetchRandomUser = () => {
    return new Promise((resolve, reject) => {
        console.log("データ取得処理開始");
        showLoading(true); // ローディングを表示
        
        setTimeout(() => {
            const success = Math.random() > 0.5; // 50%の確率で成功または失敗
            if (success) {
                resolve({ name: "太郎", age: 25 });
            } else {
                reject("データの取得に失敗しました。");
            }
        }, 3000); // 3秒待機
    });
};

const showLoading = (isLoading) => {
    if (isLoading) {
        console.log("🔄 ローディング中...");
    } else {
        console.log("✅ ローディング完了");
    }
};

fetchRandomUser()
    .then((user) => {
        console.log(`成功: ユーザー情報 -> 名前: ${user.name}, 年齢: ${user.age}`);
    })
    .catch((error) => {
        console.error("エラー:", error);
    })
    .finally(() => {
        showLoading(false);
        console.log("データ取得処理終了");
    });

// データ取得処理開始
// 🔄 ローディング中...
// (3秒後)
// 成功: ユーザー情報 -> 名前: 太郎, 年齢: 25  または  エラー: データの取得に失敗しました。
// ✅ ローディング完了
// データ取得処理終了

メリット

  • ネストが浅くなる
  • .catch()でエラーハンドリングがしやすい
  • Promise.all()で並行処理が可能

デメリット

  • 非同期処理の結果をもとに別の非同期処理を実行したり、複数の非同期処理を順番に実行する場合等で、.then() のチェーンが長くなると可読性が低下する
    // .then() のチェーンの例
    const fetchUser = () => {
        return new Promise((resolve) => {
            setTimeout(() => resolve({ id: 1, name: "花子" }), 1000);
        });
    };
    
    const fetchUserDetails = (userId) => {
        return new Promise((resolve) => {
            setTimeout(() => resolve({ age: 30, location: "東京" }), 1000);
        });
    };
    
    const fetchUserPosts = (userId) => {
        return new Promise((resolve) => {
            setTimeout(() => resolve(["投稿1", "投稿2", "投稿3"]), 1000);
        });
    };
    
    fetchUser()
        .then((user) => {
            console.log(`ユーザー取得: ${user.name} (ID: ${user.id})`);
            return fetchUserDetails(user.id); // 次の非同期処理を呼び出し
        })
        .then((details) => {
            console.log(`詳細取得: 年齢 ${details.age}, 住所 ${details.location}`);
            return fetchUserPosts(details.userId); // 次の非同期処理を呼び出し
        })
        .then((posts) => {
            console.log(`投稿数: ${posts.length}`);
        })
        .catch((error) => console.error("エラー:", error));
    
    // ユーザー取得: 花子 (ID: 1)
    // 詳細取得: 年齢 30, 住所 東京
    // 投稿数: 3件
    

async/await

Promiseの可読性をさらに向上させるために導入された。
内部的にはPromiseを使用しており、awaitPromise以外の値を渡された場合、自動的にPromise.resolve()でラップされる。
また、async関数は常にPromiseを返し、関数内で同期的な値を返した場合でもPromise.resolve()に変換される。

例(async/await)

const fetchUserDataPromise = () => {
    return new Promise((resolve) => {
        setTimeout(() => {
            const user = { name: "花子", age: 30 };
            resolve(user);
        }, 5000); // 時間のかかる処理(5秒間待機)
    });
};
const fetchUserDataAsync = async () => {
    console.log("処理開始");
    try {
        const user = await fetchUserDataPromise();
        console.log(`処理終了:こんにちは、${user.name}さん!`);
    } catch (error) {
        console.error("エラー:", error);
    }
};
fetchUserDataAsync();
// 処理開始
//(5秒間待機している間、クリックやスクロールができる)
// 処理終了:こんにちは、花子さん!

メリット

  • 同期処理のように書ける
  • エラーハンドリングが簡単(try/catch)

非同期処理の実践

APIリクエストの実例

const fetchUser = async () => {
    try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
        const user = await response.json();
        console.log(user);
    } catch (error) {
        console.error("データ取得失敗", error);
    }
};
fetchUser();
// {id: 1, name: 'Leanne Graham', username: 'Bret', email: 'Sincere@april.biz', address: {…}, …}

複数の非同期処理の逐次実行 vs 並行実行(Promise.all)

const fetchData1 = () => new Promise((resolve) => setTimeout(() => resolve("データ1"), 2000));
const fetchData2 = () => new Promise((resolve) => setTimeout(() => resolve("データ2"), 3000));
// 逐次実行
const fetchSequential = async () => {
    const data1 = await fetchData1();  // 2秒待機
    console.log("[逐次] fetchData1:", data1);
    const data2 = await fetchData2();  // さらに3秒待機
    console.log("[逐次] fetchData2:", data2);
};
// 並行実行
const fetchParallel = async () => {
    const results = await Promise.all([fetchData1(), fetchData2()]);
    console.log("[並行] fetchData1:", results[0]);
    console.log("[並行] fetchData2:", results[1]);
};
fetchSequential();
fetchParallel();
[逐次] fetchData1: データ1
[並行] fetchData1: データ1
[並行] fetchData2: データ2  // 合計3秒で完了
[逐次] fetchData2: データ2  // 合計5秒かかる

JavaScriptの非同期処理の仕組み

イベントループとは

コールスタックを監視し、適切なタイミングで非同期処理を実行する仕組み。

イベントループの構成要素

コールスタック

関数の実行履歴を管理するLIFO(後入れ先出し)のデータ構造。
動作:

  • 同期処理(通常の関数呼び出し)はコールスタックに積まれ逐次実行される。処理完了後にコールスタックから取り除かれる。
  • 非同期処理はコールスタックに積まれた後、非同期APIに渡され、コールスタックから取り除かれる。
  • 非同期APIの完了後、非同期処理完了後に実行すべき関数がマイクロタスクキューまたはマクロタスクキュー
  • コールスタックが空になったら、イベントループによりキューから処理を取り出して実行する。

非同期API

Web APIなど、非同期処理を提供する仕組み。

タスクキュー

  • マイクロタスクキュー
    Promisethen(), catch()など、高優先度の非同期処理を管理するキュー。
  • マクロタスクキュー
    setTimeout(), setInterval(), I/O処理など、通常の非同期処理を管理するキュー。

イベントループの動作の流れ

  1. コールスタックに同期処理が積まれ、逐次実行される。
  2. コールスタックが空になったら、マイクロタスクキューが空になるまで、全てのタスクを実行する。
  3. マイクロタスク処理後、マクロタスクキューから1つだけタスクを取り出して実行する。
  4. 再びマイクロタスクキューを確認(2に戻る)

※ Node.jsとブラウザでは細かい違いがあり、マイクロタスクよりも優先的に実行されるキューがある。
Node.js Docs - Understanding process.nextTick()

Discussion

junerjuner

JavaScriptはシングルスレッドで動作するため、一つの処理が完了するまで次の処理を実行できない。

今だと Web Worker や Worker threads とかあるので一概にシングルスレッドかと言われると微妙なところがあります。基本的にはシングルスレッドです くらいのニュアンスでいいかもしれません。
(勿論 メインスレッド や それぞれの Worker 自体はそれぞれシングルスレッドなのはそうです。メインスレッドは一つだけなのもそう。

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API

https://nodejs.org/api/worker_threads.html

uuuunnnniiuuuunnnnii

ありがとうございます!少し文章修正しました。
近々この辺りまとめてみます🫡いつも感謝です!