PromisesとAsync/Awaitによる非同期JavaScriptのマスター
Takashi Yamamoto
Infrastructure Engineer · Leapcell

はじめに
Web開発の世界では、JavaScriptが支配的です。そのシングルスレッドの性質は、プログラミングの特定の側面を単純化する一方で、サーバーからのデータ取得、データベースからの読み取り、ユーザー入力の処理など、時間のかかる操作を扱う際には独自の課題を提起します。これらの操作のためにメインスレッドをブロックすると、フリーズした応答性のないユーザーエクスペリエンスにつながります。これは、最新のアプリケーションでは絶対に避けたいことです。ここで非同期プログラミングが登場し、アプリケーションがユーザーインターフェース全体を停止することなく、長時間実行されるタスクを実行できるようになります。
長年、コールバックが非同期を処理するための主要なメカニズムでしたが、これは悪名高い「コールバック地獄」または「破滅のピラミッド」につながりました。つまり、深くネストされ、読みにくく、さらに保守しにくいコードです。このペインポイントを認識して、JavaScriptは進化し、エレガントで強力な構造を導入しました。それがPromise、そして subsequently、async/await
です。これらの進歩は、並行JavaScriptの記述方法を根本的に変え、コードをより読みやすく、保守しやすく、堅牢にしました。それらの根本原理を理解し、ベストプラクティスを習得することは、単なる「あれば便利」なものではなく、パフォーマンスが高くスケーラブルなアプリケーションを構築するあらゆる真剣なJavaScript開発者にとって不可欠なスキルです。この記事では、Promiseとasync/await
を解明し、それらのコアメカニズム、実装の詳細、および実践的なアプリケーションを探求して、よりクリーンで効果的な非同期コードの記述を支援します。
Promise: モダン非同期の基盤
async/await
に飛び込む前に、async/await
は基本的にそれらの上に構築されたシンタックスシュガーであるため、Promiseの概念を理解することが不可欠です。
Promiseとは何か?
その核心において、Promiseは、非同期操作の最終的な完了または失敗、およびその結果の値を表すオブジェクトです。現実世界の約束のように考えてください。リクエスト(例:「そのデータを君に渡す」)を行い、将来のある時点で、その約束は満たされる(データが正常に取得される)か、拒否される(エラーのために、例えばデータが取得できない)かのいずれかになります。
A Promiseは次の3つの状態のいずれかをとることができます。
- Pending (保留中): 初期状態。満たされることも拒否されることもありません。非同期操作はまだ進行中です。
- Fulfilled (満たされた) / Resolved (解決された): 操作は正常に完了し、Promiseには結果の値があります。
- Rejected (拒否された): 操作は失敗し、Promiseには失敗の理由(エラーオブジェクト)があります。
Promiseが満たされたり拒否されたりすると、それは**settled (確定)**されたと見なされます。確定したPromiseは、それ以上状態を変更できません。不変です。
Promiseの作成と使用
Promise
コンストラクタを使用してPromiseを作成し、executor
関数を引数として渡します。executor関数自体は、resolve
とreject
の2つの引数を受け取ります。これらはどちらも、Promiseの状態を変更するために呼び出す関数です。
const myAsyncOperation = (shouldSucceed) => { return new Promise((resolve, reject) => { // 非同期操作をシミュレートします。例: ネットワークリクエスト setTimeout(() => { if (shouldSucceed) { resolve("Data fetched successfully!"); // Promiseを満たす } else { reject(new Error("Failed to fetch data.")); // Promiseを拒否する } }, 1000); // 1秒の遅延をシミュレート }); }; // --- Promiseの利用 --- // ケース1: Promiseが解決される myAsyncOperation(true) .then((data) => { console.log("Success:", data); // 出力: Success: Data fetched successfully! }) .catch((error) => { console.error("Error (should not happen):", error.message); }); // ケース2: Promiseが拒否される myAsyncOperation(false) .then((data) => { console.log("Success (should not happen):", data); }) .catch((error) => { console.error("Error:", error.message); // 出力: Error: Failed to fetch data. });
then()
メソッドは、Promiseが満たされたときに実行されるコールバックを登録するために使用されます。これはオプションのonFulfilled
コールバックを受け取ります。catch()
メソッドはthen(null, onRejected)
のショートカットであり、Promiseが拒否されたときにコールバックを登録するために使用されます。未処理のPromise拒否を防ぎ、潜在的なエラーを処理するために、常に.catch()
ブロックを含めることが重要です。
Promiseの連鎖
Promiseのコールバックに対する最も重要な利点の1つは、連鎖できることです。then()
コールバックが値を返すと、チェーンの次のthen()
はその値を受け取ります。重要なことに、then()
コールバックが別のPromiseを返す場合、チェーンはそのネストされたPromiseが解決されるまで待機してから続行します。これにより、深くネストされた非同期操作が効果的に平坦化されます。
function step1() { console.log("Step 1: Starting..."); return new Promise((resolve) => setTimeout(() => resolve("Result from Step 1"), 1000)); } function step2(prevResult) { console.log(`Step 2: Received "${prevResult}". Doing more work...`); return new Promise((resolve) => setTimeout(() => resolve("Result from Step 2"), 1500)); } function step3(prevResult) { console.log(`Step 3: Received "${prevResult}". Finalizing...`); // このステップでエラーが発生した場合、catchブロックが処理します // throw new Error("Something went wrong in Step 3"); return Promise.resolve("Final Result!"); // 同期値のために直接解決します } step1() .then(step2) // step2はstep1の結果を受け取ります .then(step3) // step3はstep2の結果を受け取ります .then((finalResult) => { console.log("All steps completed:", finalResult); // 出力: All steps completed: Final Result! }) .catch((error) => { console.error("An error occurred during the process:", error.message); }) .finally(() => { console.log("Process finished (either success or failure)."); });
finally()
メソッドは後で導入され、Promiseが満たされたか拒否されたかに関係なく実行されるコールバックを登録できます。クリーンアップ操作に役立ちます。
Async/Await: 非同期コードの簡略化
Promiseは非同期コードを大幅に改善しましたが、ES2017でasync/await
が導入されたことで、構文上のエレガンスがさらに高まり、非同期コードが同期コードのように見え、感じられるようになりました。
async
関数
async
関数は、async
キーワードで宣言された関数です。暗黙的にPromiseを返します。関数が非Promise値を返した場合、その値で解決されるPromiseにラップされます。エラーをスローした場合、返されたPromiseは拒否されます。
async function greet() { return "Hello, async world!"; // この値は解決されたPromiseにラップされます } greet().then(message => console.log(message)); // 出力: Hello, async world! async function throwErrorExample() { throw new Error("This is an async error!"); // これにより、返されたPromiseは拒否されます } throwErrorExample().catch(error => console.error(error.message)); // 出力: This is an async error!
await
演算子
await
演算子はasync
関数内でのみ使用できます。await
は、Promiseが確定する(満たされるか拒否される)までasync
関数の実行を一時停止します。Promiseが満たされた場合、await
はその解決された値を返します。Promiseが拒否された場合、await
は拒否された値をエラーとしてスローし、それをtry...catch
ブロックでキャッチできます。
function fetchData() { return new Promise(resolve => { setTimeout(() => resolve({ id: 1, name: "Async Item" }), 2000); }); } function processData(data) { return new Promise((resolve, reject) => { setTimeout(() => { if (data && data.name) { resolve(`Processed: ${data.name.toUpperCase()}`); } else { reject(new Error("Invalid data to process.")); } }, 1000); }); } async function performOperations() { try { console.log("Starting data fetch..."); // await は fetchData() が解決されるまでここで実行を一時停止します const data = await fetchData(); console.log("Data fetched:", data); console.log("Starting data processing..."); // await は processData() が解決されるまでここで一時停止します const processedResult = await processData(data); console.log("Processed result:", processedResult); return processedResult; } catch (error) { console.error("An error occurred:", error.message); // エラーを再スローするか、デフォルト値/Promise.rejectを返すことができます throw error; } } performOperations() .then(finalResult => console.log("Overall success:", finalResult)) .catch(overallError => console.error("Overall failure:", overallError.message)); // エラーシナリオの例 async function performOperationsWithError() { try { console.log("Attempting to process invalid data..."); const invalidData = null; // 無効なデータを取得したことをシミュレート const processedResult = await processData(invalidData); // これは拒否されます console.log("Processed result:", processedResult); // この行は到達しません } catch (error) { console.error("Caught error in performOperationsWithError:", error.message); } } performOperationsWithError();
await
の周りのtry...catch
ブロックは、Promiseのcatch()
ブロックの動作を模倣して、エラーを処理するために不可欠です。
舞台裏: async/await
とイベントループ
async/await
は新しい並行プリミティブを導入するのではなく、PromiseとJavaScriptのイベントループに基づいた単なる構文変換です。await
式に遭遇すると:
async
関数の実行が一時停止します。await
されているPromiseがマイクロタスクキュー(またはI/O操作の場合はマクロタスクキュー)に置かれます。- 呼び出し関数またはイベントループに制御が戻り、他の同期コードまたは保留中のタスクを実行できるようになります。
- 待機されていたPromiseが確定すると、その
then
ハンドラ(await
が暗黙的に作成するもの)がマイクロタスクキューに追加されます。 - JavaScriptのコールスタックが空になると、イベントループはマイクロタスクをピックアップし、
async
関数は一時停止した場所から実行を再開します。解決された値を受け取るか、拒否されたエラーをスローします。
このノンブロッキングの性質は、シングルスレッドのJavaScript環境で応答性を維持するために不可欠です。
PromiseとAsync/Awaitのベストプラクティス
1. 常にエラーを処理する
Promise: Promiseチェーンの最後に常に.catch()
ブロックを付けます。未処理のPromise拒否は、サイレントな失敗や、Node.jsアプリケーションをクラッシュさせる可能性のある未処理のPromise拒否警告/エラーにつながる可能性があります。
fetch("/api/data") .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error("Failed to fetch data:", error)); // 必須!
Async/Await: await
呼び出しをtry...catch
ブロックで囲みます。
async function getData() { try { const response = await fetch("/api/data"); if (!response.ok) { // HTTPエラー(例: 404、500)をチェックします throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); console.log(data); } catch (error) { console.error("Failed to fetch data:", error); } }
2. 可読性のためにasync/await
を優先する
順次非同期操作の場合、async/await
は、複数の.then()
呼び出しを連鎖させるよりも、一般的にクリーンで読みやすいコードにつながります。
悪い(コールバック地獄):
getData(function(a) { getOtherData(a, function(b) { processData(b, function(c) { console.log(c); }); }); });
より良い(Promise):
getData() .then(a => getOtherData(a)) .then(b => processData(b)) .then(c => console.log(c)) .catch(error => console.error(error));
最良(async/await
):
async function performCombinedOperation() { try { const a = await getData(); const b = await getOtherData(a); const c = await processData(b); console.log(c); } catch (error) { console.error(error); } }
3. Promise.all
で並行操作を実行する
続行する前に完了する必要がある複数の独立した非同期操作がある場合は、それらを逐次的にawait
しないでください。ただし、真の依存関係がある場合を除きます。Promise.all()
を使用して並列で実行します。これにより、パフォーマンスが大幅に向上します。
async function getMultipleData() { try { // これら2つの独立したフェッチは並列で実行されます const [usersResponse, productsResponse] = await Promise.all([ fetch("/api/users"), fetch("/api/products") ]); const users = await usersResponse.json(); const products = await productsResponse.json(); console.log("Users:", users); console.log("Products:", products); } catch (error) { console.error("One of the fetches failed:", error); } }
Promise.all
はイテラブル(例: 配列)のPromiseを受け取り、新しいPromiseを返します。この新しいPromiseは、すべてのPromiseが満たされたときに、入力Promiseからの結果の配列として、それと同じ順序で満たされます。入力Promiseのいずれかが拒否された場合、Promise.all
は最初に拒否されたPromiseの理由で直ちに拒否されます。
Promise.allSettled()
は、すべてのPromiseの結果(成功したか失敗したか)を知る必要がある場合に役立つ別のメソッドです。これは、各Promiseの結果を記述するオブジェクト(status: 'fulfilled' | 'rejected'
, value
またはreason
)の配列を返します。
4. 同期操作のためにasync
関数を使用しない
一貫性のためにすべてにasync
を使用するのは魅力的ですが、関数が実際には非同期操作を実行しない場合(たとえば、await
を使用しない、またはPromiseを返さない場合)、それをasync
として宣言しないでください。戻り値をPromiseにラップすることによる不要なオーバーヘッドが追加されます。
5. コンテキスト(thisキーワード)に注意する
従来の関数式でthen()
またはcatch()
を使用する場合、this
コンテキストが変更される可能性があります。アロー関数はthis
コンコンテキストをレキシカルスコープに保持するため、クラスメソッド内のPromiseコールバックにとってより良い選択肢となることがよくあります。async/await
は、コードをasync
関数本体に変換する場合、この問題を自然に回避することがよくあります。
class MyService { constructor() { this.baseUrl = "/api"; } // 良い: then()コールバックにアロー関数を使用 fetchDataArrow() { return fetch(`${this.baseUrl}/data`) .then(response => response.json()) // 'this'はMyServiceインスタンスを正しく参照します .then(data => console.log(data)) .catch(error => console.error(error)); } // 同様に良い: async/awaitは、その関数本体内で'this'を自然にうまく処理します async fetchDataAsync() { try { const response = await fetch(`${this.baseUrl}/data`); // 'this'はMyServiceインスタンスを正しく参照します const data = await response.json(); console.log(data); } catch (error) { console.error(error); } } }
6. Promise.race
を競合状態のために検討する
Promise.race
はPromiseのイテラブルを受け取り、イテラブル内のPromiseのいずれかが満たされるか拒否されるとすぐに、そのPromiseの値または理由で満たされるか拒否されるPromiseを返します。これはタイムアウトなどのシナリオに役立ちます。
function timeout(ms) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error("Request timed out!")), ms) ); } async function fetchWithTimeout(url, ms) { try { const response = await Promise.race([ fetch(url), timeout(ms) ]); const data = await response.json(); console.log("Data fetched within time:", data); } catch (error) { console.error(error.message); // 遅すぎる場合は "Request timed out!" を表示します } } fetchWithTimeout("/api/slow-data", 3000); // 3秒以内に取得を試みます
結論
Promiseとasync/await
は、JavaScriptの非同期プログラミングを根本的に変革し、複雑な「コールバック地獄」から、はるかに読みやすく、保守しやすく、堅牢なパラダイムへと移行させました。Promiseは、最終的な値とエラーを処理するための基本的なメカニズムを提供し、async/await
はその基盤の上に構築され、認知負荷を大幅に軽減する同期のような構文を提供します。これらのツールを習得することは、単にエレガントなコードを書くことだけではありません。それは、応答性が高く、効率的で、エラー耐性のあるJavaScriptアプリケーションを構築することであり、現代のWebの要求の厳しい非同期的な性質をシームレスに処理できます。Promiseとasync/await
を採用し、JavaScript開発における新しいレベルの明瞭さとパワーを解き放ちましょう。