Chapter 05

非同期処理とFetch

Promise, async/await, fetch API で外部データを取得する方法を、PHP との比較で学びます。

35 min

このチャプターで学ぶこと

  • 同期処理と非同期処理の違いを理解できる
  • Promise の基本概念を理解できる
  • async/await で非同期処理を書ける
  • fetch API で JSON データを取得・表示できる
  • エラーハンドリング(try/catch)ができる

非同期処理とFetch

このチャプターでは、JavaScriptにおける最も重要かつ初学者がつまづきやすい概念のひとつ、非同期処理を学びます。外部APIからデータを取得してページに表示するという、現代のWebアプリの根幹となるパターンを習得しましょう。

なぜ非同期が必要か

まず「同期処理」と「非同期処理」の違いを理解するところから始めます。

PHPの場合(同期・ブロッキング)

PHPはリクエスト単位で処理を実行します。一行ずつ上から下へ順番に実行され、ある処理が終わるまで次の処理は始まりません(ブロッキング)。

<?php
// PHP: 上から順に実行される(ブロッキング)

$data = file_get_contents('https://api.example.com/users'); // ここで待つ
// ↑ この処理が完了するまで次の行は実行されない

$users = json_decode($data, true); // データ取得後に実行される
echo "ユーザー数: " . count($users);

PHPではこれで問題ありません。なぜならPHPはリクエストごとに独立したプロセスで動き、1ユーザーが待っても他のユーザーへの影響が少ないからです。

JavaScriptの場合(シングルスレッド・ノンブロッキング)

JavaScriptはブラウザ上で動くシングルスレッドの言語です。1つのスレッドしかないため、何かをブロッキングで待ち続けるとページ全体がフリーズしてしまいます。

PHP のイメージ:
リクエストA → [API待機中...] → 処理完了
リクエストB → [API待機中...] → 処理完了  ← 並行して動ける

JavaScript のイメージ:
UIスレッド → [API待機中(ブロック)...] → ページが完全にフリーズ!
             ↑ この間、ボタンクリックも入力も何も受け付けない

だから JavaScriptでは、時間のかかる処理(通信・ファイル読み込みなど)を「バックグラウンドで待って、終わったら教えて」という形で扱います。これが非同期処理です。

非同期のイメージ:
UIスレッド → APIリクエスト開始 → [続けて他の処理...ボタンも動く]

          [バックグラウンドで通信中]

          通信完了!コールバックを実行

コールバック(昔のやり方)

非同期処理の最初のパターンはコールバック関数でした。「終わったらこの関数を呼んでね」と渡す方式です。

// setTimeout はコールバックの典型例
// 「2秒後にこの関数を呼ぶ」という指示
setTimeout(() => {
  console.log('2秒経ちました');
}, 2000);

console.log('これはすぐ表示される'); // こちらが先に実行される

しかし非同期処理が複数ネストすると、いわゆる「コールバック地獄」に陥ります。

// コールバック地獄の例(読みにくい!)
fetchUser(userId, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      fetchAuthor(comments[0].authorId, (author) => {
        // ここまでネストするともう限界...
        console.log(author.name);
      });
    });
  });
});
📝 コールバック地獄はもう過去のもの

コールバック地獄は現代のJavaScript開発では避けられます。今は Promiseasync/await を使うことで、入れ子にならないすっきりとしたコードが書けます。コールバックパターン自体は addEventListener のように今でも使われていますが、非同期の連鎖には使いません。

Promiseの基本

Promise は「非同期処理の結果を表すオブジェクト」です。処理が完了したとき(成功または失敗)に通知を受け取れます。

Promiseの3つの状態

pending(待機中)  →  fulfilled(成功)
                  ↘  rejected(失敗)

Promiseの作成と使用

// Promiseを作る(ライブラリが中でやってくれることが多い)
const promise = new Promise((resolve, reject) => {
  // 非同期な処理を実行...

  const success = true; // 仮に成功したとして

  if (success) {
    resolve('成功しました!'); // fulfilled 状態にする
  } else {
    reject(new Error('失敗しました')); // rejected 状態にする
  }
});
// Promiseを使う(.then / .catch でつなぐ)
promise
  .then((result) => {
    // fulfilled のとき実行される
    console.log(result); // "成功しました!"
  })
  .catch((error) => {
    // rejected のとき実行される
    console.error(error.message);
  })
  .finally(() => {
    // 成功・失敗どちらでも最後に実行される
    console.log('処理が完了しました');
  });

Promiseのチェーン

.then() は新しいPromiseを返すため、連続した非同期処理をフラットに書けるのが大きなメリットです。

// コールバック地獄を Promise で書き直した例
fetchUser(userId)
  .then((user) => fetchPosts(user.id))    // user が返ってきたら次へ
  .then((posts) => fetchComments(posts[0].id)) // posts が返ってきたら次へ
  .then((comments) => fetchAuthor(comments[0].authorId))
  .then((author) => {
    console.log(author.name);
  })
  .catch((error) => {
    // どこかでエラーが起きたら全部ここでキャッチできる
    console.error(error);
  });

入れ子がなくなり、ずっと読みやすくなりました。

async / await

async/await は Promise の上に作られた構文です。非同期処理を同期処理のように書けるようにする糖衣構文(シンタックスシュガー)で、現代のJavaScriptではこちらが主流です。

基本構文

// async キーワードを関数につける → その関数は必ずPromiseを返す
async function fetchData() {
  // await を使うと Promise の解決を待てる(ブロッキングではなく、他の処理は動く)
  const result = await somePromise;
  return result;
}

.then() と async/await の比較

全く同じ処理を2つの書き方で比べてみましょう。

// 書き方1: .then() チェーン
function getUserName(userId) {
  return fetchUser(userId)
    .then((user) => {
      return user.name;
    })
    .catch((error) => {
      console.error('取得に失敗しました:', error);
    });
}

// 書き方2: async/await(こちらが推奨)
async function getUserName(userId) {
  try {
    const user = await fetchUser(userId);
    return user.name;
  } catch (error) {
    console.error('取得に失敗しました:', error);
  }
}

async/await を使うと、まるでPHPの同期処理のように上から下へ読めるコードになります。

💡 async/await を基本的に使おう

.then()async/await は同じことを表現しています。どちらを使っても動きますが、コードの読みやすさの観点から async/await を基本として使うことをおすすめします。既存コードを読むときに .then() が登場することもあるため、両方の書き方を理解しておきましょう。

await は async 関数の中でしか使えない

// トップレベルのawait(モダンなブラウザ・Node.jsのESモジュールでは使える)
const data = await fetchData(); // OK(最新環境)

// 古い環境では即時実行関数(IIFE)で囲む
(async () => {
  const data = await fetchData();
  console.log(data);
})();

fetch API の基本

fetch はブラウザ組み込みのHTTPリクエスト関数です。URLを渡すと Promise を返します。

GETリクエスト

// fetch の基本的な使い方
async function getUsers() {
  // 1. fetch でリクエストを送る(Promiseが返る)
  const response = await fetch('https://jsonplaceholder.typicode.com/users');

  // 2. レスポンスのJSONをパース(これもPromiseが返る)
  const users = await response.json();

  console.log(users); // ユーザーの配列
}

getUsers();

fetch の処理は2段階になっています:

  1. fetch(url) → HTTP通信が完了して レスポンスオブジェクト を返す
  2. response.json() → ボディをJSONとして読み込んで JavaScriptのオブジェクト に変換する
⚠️ fetch は 4xx/5xx エラーをthrowしない

Laravelのコントローラーで例外が起きると自動的にエラーレスポンスになりますが、fetch では少し違います。

const response = await fetch('https://api.example.com/users/99999');
// 404 Not Found でも fetch はエラーを throw しない!

console.log(response.ok);     // false(ステータス 200-299 のとき true)
console.log(response.status); // 404

// 必ず response.ok を確認して、失敗時は手動でthrowする
if (!response.ok) {
  throw new Error(`HTTP エラー: ${response.status}`);
}

ネットワーク自体の障害(インターネット切断など)は catch でキャッチできますが、404や500は response.ok で手動チェックが必要です。

レスポンスをDOMに表示する

fetch とDOM操作を組み合わせて、APIから取得したデータをページに表示してみましょう。

<!-- HTML -->
<div id="user-list"></div>
// JSONPlaceholder(テスト用の無料API)からユーザーを取得して表示する
async function displayUsers() {
  const container = document.querySelector('#user-list');

  // データを取得
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users = await response.json();

  // 各ユーザーのカードを生成してDOMに追加
  users.forEach((user) => {
    const card = document.createElement('div');
    card.classList.add('user-card');

    // ユーザー名
    const name = document.createElement('h3');
    name.textContent = user.name;

    // メールアドレス
    const email = document.createElement('p');
    email.textContent = user.email;

    // 会社名
    const company = document.createElement('p');
    company.textContent = `会社: ${user.company.name}`;

    card.appendChild(name);
    card.appendChild(email);
    card.appendChild(company);
    container.appendChild(card);
  });
}

// ページ読み込み後に実行
document.addEventListener('DOMContentLoaded', displayUsers);

前のチャプターで学んだDOM操作と、このチャプターで学んだ fetch を組み合わせるとこうなります。これがフロントエンドの基本パターンです。

エラーハンドリング

本番のアプリでは必ずエラーハンドリングを実装します。ネットワーク障害・サーバーエラー・予期しないデータ形式など、様々な問題が起こりえます。

try/catch の基本

async function fetchData(url) {
  try {
    const response = await fetch(url);

    // HTTP エラーを手動でチェック
    if (!response.ok) {
      throw new Error(`サーバーエラー: ${response.status}`);
    }

    const data = await response.json();
    return data;

  } catch (error) {
    // ネットワークエラーや上の throw がここでキャッチされる
    console.error('データの取得に失敗しました:', error.message);
    throw error; // 呼び出し元にもエラーを伝える
  }
}

ローディング状態とエラー表示を含む完全な実装

<!-- HTML -->
<div id="app">
  <div id="loading" class="hidden">読み込み中...</div>
  <div id="error" class="hidden"></div>
  <div id="content"></div>
</div>
/* CSS */
.hidden { display: none; }
.error-message { color: red; padding: 1rem; border: 1px solid red; border-radius: 4px; }
// ユーティリティ関数
function showLoading() {
  document.querySelector('#loading').classList.remove('hidden');
  document.querySelector('#error').classList.add('hidden');
  document.querySelector('#content').innerHTML = '';
}

function hideLoading() {
  document.querySelector('#loading').classList.add('hidden');
}

function showError(message) {
  const errorEl = document.querySelector('#error');
  errorEl.classList.remove('hidden');
  errorEl.textContent = message; // textContent を使って XSS を防ぐ
}

// メインの関数
async function loadUsers() {
  showLoading(); // ローディング表示開始

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');

    if (!response.ok) {
      throw new Error(`データの取得に失敗しました(${response.status}`);
    }

    const users = await response.json();
    const content = document.querySelector('#content');

    users.forEach((user) => {
      const div = document.createElement('div');
      div.textContent = user.name;
      content.appendChild(div);
    });

  } catch (error) {
    // ユーザーにわかりやすいメッセージを表示
    showError('データを読み込めませんでした。ネットワーク接続を確認してください。');
    console.error(error); // 開発用に詳細はコンソールに出す
  } finally {
    hideLoading(); // 成功・失敗に関わらずローディングを消す
  }
}

document.addEventListener('DOMContentLoaded', loadUsers);

try/catch/finally の使い方はPHPと同じ感覚で使えます。

<?php
// PHP の try/catch(同じ概念)
try {
    $response = Http::get('https://api.example.com/users');
    $users = $response->json();
} catch (\Exception $e) {
    Log::error($e->getMessage());
    return back()->withErrors(['message' => 'データの取得に失敗しました']);
} finally {
    // 最後に必ず実行
}

POSTリクエスト

データをサーバーに送信するには、fetch にオプションを渡します。

async function createPost(title, body) {
  const postData = {
    title: title,
    body: body,
    userId: 1,
  };

  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',                          // HTTPメソッド
    headers: {
      'Content-Type': 'application/json',    // JSONを送る宣言
    },
    body: JSON.stringify(postData),          // オブジェクトをJSON文字列に変換
  });

  if (!response.ok) {
    throw new Error('投稿の作成に失敗しました');
  }

  const createdPost = await response.json();
  console.log('作成されました:', createdPost);
  return createdPost;
}

Laravelで言えば、フォームから POST する感覚に近いです。ただし Content-Type: application/json ヘッダーをつけて JSON.stringify でボディを作る点が異なります。

// PHP (Laravel): Httpファサードで POST
$response = Http::post('https://api.example.com/posts', [
    'title' => $title,
    'body'  => $body,
]);
// JavaScript: fetch で POST
const response = await fetch('https://api.example.com/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title, body }),
});

PUT / DELETE も同様

// PUT リクエスト(更新)
const response = await fetch(`https://api.example.com/posts/${id}`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(updatedData),
});

// DELETE リクエスト(削除)
const response = await fetch(`https://api.example.com/posts/${id}`, {
  method: 'DELETE',
});
✍ やってみよう:投稿一覧をカードで表示する

JSONPlaceholder API から投稿の最初の10件を取得して、カード形式で表示してみましょう。

要件:

  • https://jsonplaceholder.typicode.com/posts から投稿を取得する
  • 最初の10件だけ表示する(slice(0, 10) を使う)
  • 各投稿のタイトルと本文をカードで表示する
  • データ取得中は「読み込み中…」を表示する
  • エラーが起きたらエラーメッセージを表示する

HTML:

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>投稿一覧</title>
  <style>
    body { font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 1rem; }
    .card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
    .card h3 { margin: 0 0 0.5rem; font-size: 1rem; text-transform: capitalize; }
    .card p { margin: 0; color: #64748b; font-size: 0.875rem; }
    .loading { text-align: center; color: #64748b; padding: 2rem; }
    .error { color: #dc2626; border: 1px solid #dc2626; border-radius: 8px; padding: 1rem; }
  </style>
</head>
<body>
  <h1>投稿一覧</h1>
  <div id="loading" class="loading">読み込み中...</div>
  <div id="error" class="error" style="display:none;"></div>
  <div id="posts"></div>
  <script src="main.js"></script>
</body>
</html>

JavaScript(main.js)の実装例:

async function loadPosts() {
  const loadingEl = document.querySelector('#loading');
  const errorEl = document.querySelector('#error');
  const postsEl = document.querySelector('#posts');

  try {
    // APIからデータを取得
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');

    if (!response.ok) {
      throw new Error(`エラーが発生しました: ${response.status}`);
    }

    const allPosts = await response.json();

    // 最初の10件だけ使う
    const posts = allPosts.slice(0, 10);

    // ローディングを非表示にする
    loadingEl.style.display = 'none';

    // 各投稿のカードを作成
    posts.forEach((post) => {
      const card = document.createElement('div');
      card.classList.add('card');

      const title = document.createElement('h3');
      title.textContent = post.title; // XSS対策でtextContentを使う

      const body = document.createElement('p');
      body.textContent = post.body;

      card.appendChild(title);
      card.appendChild(body);
      postsEl.appendChild(card);
    });

  } catch (error) {
    // エラー表示
    loadingEl.style.display = 'none';
    errorEl.style.display = 'block';
    errorEl.textContent = 'データを読み込めませんでした。しばらくしてから再度お試しください。';
    console.error(error);
  }
}

loadPosts();

発展課題: ユーザーID別にフィルタリングするボタンを追加してみましょう。https://jsonplaceholder.typicode.com/posts?userId=1 のようにクエリパラメーターを使えます。

// URLSearchParams を使ってクエリパラメーターを構築する
const params = new URLSearchParams({ userId: 1, _limit: 10 });
const url = `https://jsonplaceholder.typicode.com/posts?${params}`;
const response = await fetch(url);

まとめ

非同期処理とFetch APIのポイントをまとめます:

これでJavaScriptのDOM操作・イベント処理・非同期処理という3つの柱が揃いました。次のステップでは、これらの知識を活かして React を学びます。Reactはここまで学んだDOM操作や非同期処理を抽象化して、より宣言的にUIを構築できるライブラリです。