JavaScript | 非同期処理:設計・理解の深化 - プロとしての非同期設計

JavaScript JavaScript
スポンサーリンク

「プロとしての非同期設計」って何が違うのか

非同期処理を「書ける」ようになるのは、正直そんなに難しくありません。
async/await を覚えて、fetch を呼んで、try/catch を付ければ、とりあえず動くものは作れます。

でも、プロとして非同期を「設計する」となると、見るべきものが一気に増えます。
読みやすさ、テストしやすさ、エラーの扱い、UI との境界、パフォーマンス、将来の変更余地…。

ここで話したいのは、
「プロの非同期設計って、何を意識しているのか」
「初心者がどこからその感覚に近づいていけるのか」
というところです。

コードのテクニックというより、
非同期をどう“考えるか”にフォーカスして話していきます。


プロの非同期設計は「呼び出し側」から逆算する

まず「どう呼ばれたいか」を決める

プロっぽい非同期設計の一番の特徴は、
「中身から書き始めない」ことです。

いきなりこう書くのではなく。

async function fetchUser() {
  const res = await fetch("/api/user");
  return res.json();
}
JavaScript

先に「呼び出し側の理想の形」を考えます。

例えば、ユーザーページを作るなら、こう書けたら気持ちいいな、と想像します。

const { user, posts } = await loadUserPageData(userId);
renderUserPage(user, posts);
JavaScript

この「理想の一行」を先に決めてから、
それを実現するために中身の非同期処理を設計していきます。

例えば、こう分解されていきます。

async function fetchUser(userId) { ... }
async function fetchUserPosts(userId) { ... }

async function loadUserPageData(userId) {
  const [user, posts] = await Promise.all([
    fetchUser(userId),
    fetchUserPosts(userId),
  ]);
  return { user, posts };
}
JavaScript

ここで重要なのは、
「非同期関数の設計を、“使う側のコード”から逆算している」
ということです。

プロの非同期設計は、
「この関数を使う人が、どう書けたら楽か?」
を常に意識しています。

「一文で説明できる責務」にする

もう一つのポイントは、
「この非同期関数は、一言で何をする関数か?」
をはっきりさせることです。

例えば、

fetchUser は「ユーザーを取得する」
fetchUserPosts は「ユーザーの投稿を取得する」
loadUserPageData は「ユーザーページに必要なデータをまとめて取得する」

このくらいスパッと説明できると、
関数の責務がぶれていません。

逆に、こういう関数名と中身は危険です。

async function doInit() {
  const res = await fetch("/api/user");
  const user = await res.json();
  const postsRes = await fetch(`/api/posts?userId=${user.id}`);
  const posts = await postsRes.json();
  renderUser(user);
  renderPosts(posts);
  saveToCache(user, posts);
}
JavaScript

「何をする関数?」と聞かれても、
「いろいろやる関数」としか答えられません。

プロとしての非同期設計では、
「一つの関数に“いろいろ”を詰め込まない」
という感覚がかなり強いです。


プロは「失敗の仕方」まで設計している

成功だけでなく「どう壊れるか」も仕様

非同期処理は、失敗が前提です。
ネットワークは落ちるし、サーバーはエラーを返すし、JSON は壊れます。

プロの非同期設計は、
「成功したときに何を返すか」だけでなく、
「失敗したときにどう振る舞うか」まで仕様として決めます。

例えば、こういう関数があるとします。

async function fetchCurrentUser() {
  const res = await fetch("/api/user");
  if (!res.ok) {
    throw new Error("ユーザー取得に失敗しました");
  }
  return res.json();
}
JavaScript

ここで終わらせず、
頭の中ではこう整理しています。

この関数は、ネットワークエラーのときは何を投げるか
HTTP ステータスが 401, 403, 500 のときはどう扱うか
JSON パースに失敗したらどうなるか

そして、それをコメントやドキュメントとして残します。

/**
 * 現在のユーザーを取得する。
 *
 * 成功時:
 *   - ユーザーオブジェクトを解決する Promise を返す。
 *
 * 失敗時:
 *   - ネットワークエラー: fetch が投げる TypeError をそのまま伝播。
 *   - HTTP ステータスが 2xx 以外: Error を投げる(メッセージにステータスコードを含む)。
 *   - JSON パースエラー: SyntaxError をそのまま伝播。
 */
JavaScript

ここがプロっぽいポイントです。
「例外は“たまたま起きるもの”ではなく、“仕様として設計するもの”」
という感覚を持っています。

「投げるか」「返すか」を一貫させる

もう一つ大事なのが、
「この API は失敗を例外で表現するのか、戻り値で表現するのか」
を決めて、一貫させることです。

例えば、こういうスタイル。

async function fetchUserSafe(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { ok: false, error: new Error("ユーザー取得に失敗しました") };
    }
    const user = await res.json();
    return { ok: true, user };
  } catch (err) {
    return { ok: false, error: err };
  }
}
JavaScript

この関数は「例外を外に投げない」という仕様です。
呼び出し側は、常に ok を見ればよい。

プロの設計では、
「このプロジェクトでは、外向きの API はこのスタイルで統一しよう」
というルールを決めて、ブレないようにします。

初心者がやりがちなのは、
ある関数は例外を投げ、別の関数は { ok: false } を返し、
呼び出し側が毎回「これはどっちだっけ?」と迷う状態です。

プロはそこを「迷わせない」ように設計します。


プロは「テストしやすさ」を最初から組み込む

外部依存を中に埋め込まない

非同期処理は、テストが難しくなりがちです。
ネットワーク、時間、ストレージなど、外部要因が多いからです。

プロの非同期設計は、
「テストのときに差し替えられるように書く」
ことを最初から意識しています。

例えば、こうではなく。

async function fetchCurrentUser() {
  const res = await fetch("/api/user");
  return res.json();
}
JavaScript

こうします。

async function fetchCurrentUser(fetchImpl) {
  const res = await fetchImpl("/api/user");
  return res.json();
}
JavaScript

本番では fetch を渡し、
テストではフェイクの fetch を渡せます。

async function fakeFetch() {
  return {
    ok: true,
    json: async () => ({ id: 1, name: "テスト太郎" }),
  };
}
JavaScript

これで、
「ネットワークに依存しないテスト」が簡単に書けます。

プロは、
「テストのために後からモックをねじ込む」のではなく、
「最初からモックしやすい形に設計する」
という発想で非同期関数を書きます。

ロジックと副作用を分ける

もう一つのテストしやすさのポイントは、
「ロジック」と「外部とのやり取り」を分けることです。

例えば、こういう関数があったとします。

async function initPage() {
  const res = await fetch("/api/user");
  const user = await res.json();
  const header = document.querySelector("#header");
  header.textContent = `こんにちは、${user.name}さん`;
}
JavaScript

これをテストしようとすると、
fetch も DOM も全部相手にしないといけません。

プロは、こう分解します。

async function fetchCurrentUser(fetchImpl) {
  const res = await fetchImpl("/api/user");
  return res.json();
}

function formatGreeting(user) {
  return `こんにちは、${user.name}さん`;
}

function renderHeader(element, text) {
  element.textContent = text;
}
JavaScript

これで、
formatGreeting は完全に同期で、外部依存もなく、テストが超簡単です。

非同期の中にも「ただのロジック」が必ずあります。
プロはそこを見つけて、切り出して、テストしやすくします。


プロは「時間」と「順番」を設計として扱う

レースコンディションを“運”に任せない

非同期バグの多くは、
「どっちが先に終わるか分からない」
「キャンセルしたはずの処理が後から結果を反映する」
といった、時間と順番の問題です。

プロの非同期設計は、
そこを「運任せ」にしません。

例えば、検索ボックスの例。

素直に書くと、こうなります。

input.addEventListener("input", async () => {
  const keyword = input.value;
  const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
  const data = await res.json();
  renderResult(data);
});
JavaScript

これだと、
「古いリクエストの結果が後から返ってきて、最新の結果を上書きする」
というバグが起きます。

プロは、こういうガードを入れます。

let latestRequestId = 0;

input.addEventListener("input", async () => {
  const keyword = input.value;
  const id = ++latestRequestId;

  const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
  const data = await res.json();

  if (id !== latestRequestId) {
    return; // 古いリクエストなので無視
  }

  renderResult(data);
});
JavaScript

ここでやっているのは、
「時間と順番を、ID という形でコードに持ち込んでいる」
ということです。

プロは、
「非同期は順番が保証されない」
という前提を受け入れたうえで、
「それでも壊れないルール」をコードに埋め込みます。

キャンセル可能性を設計に入れる

もう一つ、プロがよく意識するのが「キャンセル」です。

例えば、画面 A で API を叩いている途中に、
ユーザーが画面 B に移動したとします。

そのとき、
「画面 A 用のリクエスト結果が、後から UI に反映されてしまう」
というバグがよく起きます。

プロは、
AbortController などを使って「キャンセル可能な API」を設計します。

async function fetchUser(id, { signal } = {}) {
  const res = await fetch(`/api/users/${id}`, { signal });
  if (!res.ok) throw new Error("ユーザー取得に失敗しました");
  return res.json();
}
JavaScript

画面側では、こう扱います。

const controller = new AbortController();

async function loadUserPage(id) {
  try {
    const user = await fetchUser(id, { signal: controller.signal });
    renderUser(user);
  } catch (err) {
    if (err.name === "AbortError") {
      return; // キャンセルされた場合は何もしない
    }
    showError(err.message);
  }
}

// 画面を離れるとき
controller.abort();
JavaScript

プロの非同期設計は、
「ユーザーが途中でやめる」「画面が変わる」
といった現実の動きを前提にしています。


プロは「境界」をはっきり引く

同期と非同期の境界

プロは、
「どこから先が非同期の世界か」
「どこまでが同期の世界か」
を意識して境界を引きます。

例えば、こういう構造を作ります。

データ取得層(非同期)
状態管理層(同期)
UI 層(同期)

コードで言うと、こんなイメージです。

async function fetchUser(id) { ... }          // 非同期
async function fetchPosts(userId) { ... }     // 非同期

function createUserStore() {                  // 同期
  let state = { user: null, posts: [] };
  return {
    setUser(user) { state.user = user; },
    setPosts(posts) { state.posts = posts; },
    getState() { return state; },
  };
}

function renderUserPage(state) {              // 同期
  // DOM 更新
}
JavaScript

そして、これらを組み合わせる「オーケストレーション層」を非同期で書きます。

async function loadUserPage(id, { store }) {
  const [user, posts] = await Promise.all([
    fetchUser(id),
    fetchPosts(id),
  ]);
  store.setUser(user);
  store.setPosts(posts);
  renderUserPage(store.getState());
}
JavaScript

プロは、
「非同期の世界」と「同期の世界」をごちゃ混ぜにせず、
レイヤーごとに責務を分けます。

これによって、
テストしやすさ、変更しやすさ、見通しの良さが一気に上がります。


初心者が「プロとしての非同期設計」に近づくための視点

最後に、あなたに持っていてほしい問いをまとめます。
これを自分のコードに何度も投げかけてみてください。

この非同期関数は、「どう呼ばれたいか」から逆算して設計されているか。
この関数は、一文で「何をする関数」と説明できるか。
成功だけでなく、「どう失敗しうるか」を言葉にできるか。
外部依存(fetch、DOM、時間など)を中に埋め込まず、差し替えられる形にできているか。
時間と順番の問題(レースコンディション、キャンセル)を、ルールとしてコードに落とし込めているか。
同期と非同期の境界、UI とデータ取得の境界が、コード上で見える形になっているか。

おすすめの練習は、
自分の非同期処理を一つ選んで、こうしてみることです。

まず「理想の呼び出し側のコード」を書く。
次に「その理想を実現するために、関数をどう分けるか」を考える。
最後に「失敗の仕方」「テストのしやすさ」「時間と順番」をチェックする。

それを何度か繰り返すと、
あなたの中に「プロとしての非同期設計の感覚」が、確実に育っていきます。

async/await を知っているだけの人と、
非同期を“設計できる”人の差は、そこにあります。

タイトルとURLをコピーしました