shibomb

JavaScript非同期処理:Promiseとasync/awaitでアプリのフリーズを防ぐ

Webサイトのボタンをクリックしたら画面が固まってしまった、サーバーからデータを取得している間、ユーザーが何も操作できずに待たされてしまう…。そんな経験はありませんか?これらの問題の多くは、プログラムの処理の順序、特に「非同期処理」をうまく扱えていないことが原因です。この記事では、なぜアプリがフリーズするのかという基本的な仕組みから、Promiseasync/await といったモダンな JavaScript非同期 処理の実践テクニックまでを解説します。快適な ノンブロッキングUI を実現し、ユーザーに愛されるアプリケーションを作るための第一歩を踏み出しましょう。

プログラミングの「非同期処理」って何?なぜ今、必須スキルなのか

非同期処理 とは、ある処理が終わるのを待たずに、次の処理へ進むことができるプログラミングの仕組みです。言葉だけだと少し難しいので、レストランのウェイターの仕事に例えてみましょう。

  • 同期的なウェイター: お客様Aから注文を受けたら、厨房に行って料理ができるまでずっと待ち続けます。料理が完成してお客様Aに届け終わるまで、次のお客様Bの注文を取りにいきません。これではお店の回転率が非常に悪くなってしまいます。
  • 非同期的なウェイター: お客様Aから注文を受けたら、すぐに厨房に伝票を渡します。そして、料理ができるのを待つ間に、お客様Bの注文を取りに行ったり、お水を注いだりします。料理ができたら厨房から呼ばれるので、そのタイミングでお客様Aに届けに行きます。

プログラミングの世界でも同じです。特にWebアプリケーションでは、APIサーバーからデータを取得したり、大きなファイルを読み込んだりと、完了までに時間がかかる処理が頻繁に発生します。もしこれらの処理を「同期的」に行うと、処理が終わるまでプログラム全体が停止してしまい、ユーザーは何も操作できなくなってしまいます。

現代のWeb開発では、ユーザーを待たせない快適な操作性、つまり ノンブロッキングUI が当たり前に求められます。そのため、時間のかかる処理は「非同期」で行い、「処理が終わったら知らせてね」とコンピューターに依頼しておくスタイルが基本です。この 非同期処理 を使いこなすことが、現代のプログラマーにとって必須スキルとなっているのです。

同期処理の限界:なぜアプリは『フリーズ』してしまうのか?

では、なぜ同期処理はアプリケーションを「フリーズ」させてしまうのでしょうか。その原因は、多くのプログラミング言語、特にブラウザで動作するJavaScriptの実行モデルにあります。JavaScriptは基本的に シングルスレッド で動作します。これは「一度に一つのことしかできない」という意味です。

例えば、ボタンをクリックしたときに非常に重たい計算処理が始まる、というコードを考えてみましょう。

// HTMLに <button id="myButton">重い処理を開始</button> があるとする

const myButton = document.getElementById('myButton');

myButton.addEventListener('click', () => {
  console.log('処理を開始します...');

  // 非常に重い同期的な処理(例:巨大なループ)
  let result = 0;
  for (let i = 0; i < 5000000000; i++) {
    result += i;
  }
  
  console.log('処理が完了しました:', result);
  alert('完了しました!');
});

このボタンをクリックすると、for ループの計算が始まります。この計算には数秒かかるかもしれませんが、問題はその間、JavaScriptエンジンは他のタスクを一切実行できないことです。これには、画面の再描画や、他のボタンへの反応、テキスト入力といったユーザーインターフェース (UI) に関するすべての処理が含まれます。

結果として、ユーザーから見ると、

  • クリックしたボタンが押されたまま戻らない
  • ページ内の他の要素がクリックできない
  • 画面が白っぽくなり「応答なし」と表示される (ブラウザによる)

といった現象が起きると考えられます。これが、アプリケーションが「フリーズ」するメカニズムです。シングルスレッドの処理ラインを一つの重い同期処理が占拠してしまうことで、UI更新のタスクが実行待ちになり、画面が固まってしまうのです。

モダンな非同期処理の基本:Promiseでコールバック地獄から脱却しよう

このフリーズ問題を解決するのが非同期処理です。かつてのJavaScriptでは、非同期処理を実装するために「コールバック関数」が多用されていました。しかし、処理Aが終わったら処理B、Bが終わったらC…と処理が連鎖すると、コードがどんどんネストしてしまい、非常に読みにくくなる「コールバック地獄」という問題がありました。

// コールバック地獄の例(イメージ)
api.fetchUser('nozomono', (error1, user) => {
  if (error1) {
    handleError(error1);
  } else {
    api.fetchPosts(user.id, (error2, posts) => {
      if (error2) {
        handleError(error2);
      } else {
        api.updateUI(posts, (error3) => {
          if (error3) {
            handleError(error3);
          } else {
            console.log('すべての処理が完了');
          }
        });
      }
    });
  }
});

この問題をエレガントに解決するために登場したのが Promise です。Promise は「非同期処理の最終的な結果を表すオブジェクト」です。最初は「処理中 (pending)」の状態ですが、処理が成功すれば「成功 (fulfilled)」、失敗すれば「失敗 (rejected)」の状態に変化します。

Promise を使うと、先ほどのコールバック地獄は .then() メソッドで繋げる形に書き換えられ、処理の流れが格段に分かりやすくなります。

// Promiseを使った改善例
api.fetchUser('nozomono')
  .then(user => {
    // fetchUserが成功したら、その結果(user)を使って次の処理へ
    return api.fetchPosts(user.id);
  })
  .then(posts => {
    // fetchPostsが成功したら、その結果(posts)を使って次の処理へ
    return api.updateUI(posts);
  })
  .then(() => {
    // updateUIが成功したら実行
    console.log('すべての処理が完了');
  })
  .catch(error => {
    // 途中のどこかでエラーが発生したら、ここでまとめてキャッチ
    handleError(error);
  });

ネストが解消され、上から下へ時系列に処理を追えるようになりました。また、エラーハンドリングも .catch() で一元管理できるため、コードの堅牢性も向上します。Promise は、現代の JavaScript非同期 処理の基盤となる非常に重要な概念です。

async/awaitで同期のように読みやすい非同期コードを書く実践テクニック

Promise はコールバック地獄を解決しましたが、さらに直感的に、まるで同期処理を書いているかのように非同期コードを記述できるのが async/await です。これはES2017で導入された構文で、内部的には Promise をベースに動作しています。

async/await を使うためのルールは2つだけです。

  1. 非同期処理を待ちたい関数の前に async を付ける。
  2. Promise を返す処理 (関数の呼び出しなど) の前に await を付ける。

先ほどの .then() チェーンのコードを async/await で書き換えてみましょう。

// async/await を使ったさらに直感的な書き方
async function fetchUserDataAndRender() {
  try {
    // fetchUserが終わるまで「待つ」
    const user = await api.fetchUser('nozomono');
    
    // fetchPostsが終わるまで「待つ」
    const posts = await api.fetchPosts(user.id);
    
    // updateUIが終わるまで「待つ」
    await api.updateUI(posts);
    
    console.log('すべての処理が完了');
  } catch (error) {
    // tryブロック内のいずれかのawaitでエラーが発生したらキャッチ
    handleError(error);
  }
}

// async関数を呼び出す
fetchUserDataAndRender();

いかがでしょうか。コードがさらにシンプルになり、通常の同期的なプログラムと同じように上から順に処理が実行されるように読めます。エラーハンドリングも、馴染み深い try...catch 構文で書けるため、非常に分かりやすいです。

ここで重要なのは、await はプログラムの実行を「待つ」ように見えますが、実際には JavaScriptのスレッドをブロックしない という点です。await が使われると、JavaScriptエンジンは Promise の処理が終わるのを待つ間、関数の実行を一時中断し、他のタスク(UIの更新など)を実行することができます。そして、Promise の処理が完了したら、中断した場所から関数の実行を再開します。これが async/awaitノンブロッキングUI を実現できる理由です。

非同期処理で陥りがちなワナ(エラーハンドリング、競合状態)と回避策

非同期処理は非常に強力ですが、正しく扱わないと思わぬ不具合を引き起こします。特に初心者が陥りがちなワナが「エラーハンドリングの漏れ」と「競合状態」です。

エラーハンドリングの漏れ

非同期処理で発生したエラーは、適切にキャッチしないとプログラムが静かに失敗する「サイレントエラー」の原因になります。

  • Promiseの場合: .then() のチェーンの最後には、.catch() を付けるのが一般的です。これを忘れると、Promise が失敗 (rejected) しても何も起こらず、なぜ処理が進まないのか分からなくなってしまう場合があります。
  • async/awaitの場合: await を使う可能性のあるコードは、原則として try...catch で囲むことが推奨されます。catch 節を忘れると、Promise が失敗した際に Uncaught (in promise) というエラーがコンソールに出力され、アプリケーションが不安定になる可能性があります。

競合状態 (Race Condition)

競合状態とは、複数の非同期処理が意図しない順序で完了することで、データの不整合や予期せぬ結果を引き起こす問題です。例えば、ユーザーが「保存」ボタンを素早く2回クリックしたケースを考えてみましょう。

  1. 1回目のクリックで、保存処理A(非同期)が開始される。
  2. 保存処理Aが終わる前に、2回目のクリックで保存処理B(非同期)が開始される。
  3. もし処理Bが処理Aより先に完了してしまうと、その後に完了した処理Aの結果でデータが上書きされ、最新のデータが失われてしまう可能性があります。

このような競合状態を避けるための基本的な対策は、処理中は新たな処理を受け付けないように制御する ことです。

  • UIでの対策: ボタンをクリックしたら、APIからの応答が返ってくるまでボタンを無効化 (disabled 属性を true にする) するのが最もシンプルで効果的な方法の一つです。
  • ロジックでの対策: isLoadingのようなフラグ変数を持ち、処理を開始する際にフラグを true にします。もしフラグが true の間は、新たな処理要求を無視するように実装します。処理が完了または失敗したら、フラグを false に戻します。

非同期処理を使いこなし、ユーザー体験を劇的に向上させるアプリ開発へ

ここまで学んできた非同期処理のテクニックは、単にプログラムを動かすためだけのものではありません。その本質は、ユーザー体験 (UX) を向上させる ことにあります。

例えば、APIからデータを取得する際には、ただ待たせるのではなく、非同期処理の待ち時間を利用してローディングスピナーやスケルトンスクリーン(コンテンツの骨組みのような表示)を見せることで、ユーザーは「今、読み込んでいるんだな」と安心して待つことができます。

また、互いに依存しない複数のAPIリクエストがある場合、Promise.all() を使うとそれらを並行して実行できます。これにより、一つずつ直列に実行するよりも、全体の待ち時間を大幅に短縮することが可能です。

async function fetchInitialData() {
  console.log('ユーザー情報と製品リストの取得を開始します...');
  
  // 2つの非同期処理を並行して開始する
  const userPromise = api.fetchUser('nozomono');
  const productsPromise = api.fetchProducts();
  
  // 両方の処理が終わるのを待つ
  const [user, products] = await Promise.all([userPromise, productsPromise]);
  
  console.log('両方のデータを取得完了!');
  // ここでUIを更新する
  renderPage(user, products);
}

非同期処理 をマスターすることは、フリーズしない快適なアプリを作るための基礎体力です。今日学んだ Promiseasync/await を武器に、ユーザーが「サクサク動いて気持ちいい」と感じるようなアプリケーション開発を目指していきましょう。

関連記事