プロミスと非同期JavaScript

JavaScriptは、シングルスレッドで動作するプログラミング言語であり、
非同期処理を効果的に管理するために、イベントループやコールバック関数、プロミス(Promises)、
async/awaitなどのメカニズムが用意されています。これらの機能を利用することで、ブラウザやサーバーでの非同期操作を効率的に扱うことができます。本記事では、非同期処理の基本からプロミス、さらにasync/awaitの活用法について詳しく説明します。

JavaScriptにおける非同期処理の必要性

JavaScriptは非同期処理をサポートしており、
これにより長時間かかる操作(例: ネットワークリクエスト、ファイルの読み込み、タイマーなど)を
実行している間も、他のコードの実行を妨げることなく、
スムーズなユーザー体験を提供することができます。

非同期処理を理解するために、JavaScriptがシングルスレッドで動作することを念頭に置く必要があります。
シングルスレッドでは、一度に一つのことしか実行できません。
そのため、長時間実行される操作があると、他のコードの実行がブロックされ、
アプリケーションがフリーズする可能性があります。

コールバック関数

非同期処理の最も基本的な方法はコールバック関数です。
コールバック関数とは、ある処理が完了した後に呼び出される関数のことです。
コールバック関数を使用することで、非同期の結果を処理することができます。

例: setTimeoutを使った非同期処理

console.log("Start");

setTimeout(() => {
console.log("This message is delayed by 2 seconds.");
}, 2000);

console.log("End");

上記のコードでは、setTimeout関数を使って2秒後にコールバック関数が実行されます。
この間、JavaScriptの実行はブロックされず、console.log("End")が即座に実行されます。

コールバック地獄(Callback Hell)

コールバック関数を多用すると、次第に「コールバック地獄」と呼ばれる問題に直面することがあります。
コールバック地獄は、非同期操作がネストしすぎてコードが読みにくく、保守が困難になる現象です。

例: コールバック地獄の例

doSomething((result1) => {
doSomethingElse(result1, (result2) => {
doAnotherThing(result2, (result3) => {
doFinalThing(result3, (result4) => {
console.log("All done!");
});
});
});
});

このように、複数の非同期操作が連続して実行される場合、
コールバック関数が入れ子状に増えていき、コードが非常に複雑になります。

プロミス(Promise)による非同期処理

コールバック地獄を回避し、非同期処理をより簡潔かつ直感的に扱うために、
JavaScriptではプロミス(Promise)という仕組みが導入されました。
プロミスは、将来のある時点で結果が返されることを約束するオブジェクトです。

プロミスの基本構造

プロミスは、以下の3つの状態を持ちます。

Pending(保留中)

初期状態。処理がまだ完了していない。

Fulfilled(成功)

処理が成功し、結果が返された状態。

Rejected(失敗)

処理が失敗し、エラーが返された状態。

プロミスは、resolverejectの2つのコールバック関数を引数に取る関数を使って作成されます。

基本的なプロミスの作成と使用

const promise = new Promise((resolve, reject) => {
const success = true;

if (success) {
resolve("The operation was successful!");
} else {
reject("The operation failed.");
}
});

promise
.then((message) => {
console.log(message);
})
.catch((error) => {
console.error(error);
});

この例では、promiseが作成され、
successの値に基づいてresolveまたはrejectが呼び出されます。
resolveが呼ばれると、プロミスはthenメソッドを使って成功時の処理を行います。
rejectが呼ばれると、catchメソッドを使ってエラー処理を行います。

チェーン(連鎖)による処理

プロミスの強力な機能の一つは、thenメソッドを使ったチェーン処理です。
これにより、複数の非同期操作を直線的に並べて処理することができます。

例: プロミスチェーン

const fetchData = new Promise((resolve, reject) => {
setTimeout(() => resolve("Data fetched"), 1000);
});

fetchData
.then((data) => {
console.log(data);
return "Processing data";
})
.then((message) => {
console.log(message);
return "Data processed";
})
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error("Error:", error);
});

この例では、複数のthenメソッドがチェーンで繋がれており、順番に処理が行われます。
thenの戻り値は、次のthenに渡されます。エラーが発生した場合は、catchメソッドで処理されます。

async/awaitによる非同期処理

ES2017(ES8)で導入されたasyncawaitキーワードは、
プロミスをより直感的に扱えるようにするための新しい構文です。
async/awaitを使うことで、プロミスチェーンを平坦化し、
非同期処理をまるで同期処理のように記述できます。

async関数

asyncキーワードを使って宣言された関数は、自動的にプロミスを返します。
関数内でawaitキーワードを使うことで、プロミスの結果を待つことができます。

例: async関数の基本

async function fetchData() {
return "Data fetched";
}

fetchData().then((data) => console.log(data)); // Data fetched

この例では、fetchData関数がasyncキーワードで宣言されており、自動的にプロミスを返します。

awaitキーワード

awaitキーワードは、プロミスが解決されるまで関数の実行を一時停止し、その結果を返します。
awaitasync関数内でのみ使用できます。

例: async/awaitによる非同期処理

function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function asyncFunction() {
console.log("Waiting for 2 seconds...");
await delay(2000);
console.log("Done!");
}

asyncFunction();
// Waiting for 2 seconds...
// (2秒後)
// Done!

この例では、delay関数が指定された時間だけ待機するプロミスを返し、
asyncFunction内でawaitを使ってその完了を待っています。

エラーハンドリング

async/awaitを使った非同期処理のエラーハンドリングは、try...catch構文を使用して行います。
これにより、awaitで待機しているプロミスが失敗した場合に、
そのエラーをキャッチして処理することができます。

例: async/awaitによるエラーハンドリング

async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}

fetchData();

この例では、fetch関数が非同期にデータを取得しますが、
もしリクエストが失敗した場合、catchブロックでエラーが処理されます。

非同期処理の設計パターン

非同期処理を設計する際には、いくつかのパターンやベストプラクティスを考慮する必要があります。
以下は、よく使用される設計パターンです。

並列処理と逐次処理

非同期処理では、複数の非同期操作を並列に行うか、順番に行うかを選択することが重要です。

並列処理

Promise.allを使用して、複数のプロミスを並列に処理します。すべてのプロミスが解決されるまで待機し、その結果をまとめて返します。

例: 並列処理

const promise1 = new Promise((resolve) => setTimeout(resolve, 1000, "Result 1"));
const promise2 = new Promise((resolve) => setTimeout(resolve, 2000, "Result 2"));

Promise.all([promise1, promise2])
.then((results) => {
console.log(results); // ["Result 1", "Result 2"]
})
.catch((error) => {
console.error("Error:", error);
});

逐次処理

非同期処理を順番に行う場合、async/awaitを使うことで自然な形で実装できます。

例: 逐次処理

async function sequentialExecution() {
const result1 = await new Promise((resolve) => setTimeout(resolve, 1000, "Result 1"));
console.log(result1);

const result2 = await new Promise((resolve) => setTimeout(resolve, 2000, "Result 2"));
console.log(result2);
}

sequentialExecution();
// Result 1
// (1秒後)
// Result 2
// (2秒後)

競争条件(Race Condition)の回避

非同期処理では、競争条件を回避することが重要です。
競争条件とは、複数の非同期操作が予期せぬ順序で実行されることで、意図しない結果を生む問題です。

解決策

必要に応じて処理の順序を制御する、もしくはPromise.raceを使用して、
最初に解決されたプロミスの結果を使用する方法があります。

例: Promise.raceによる最初の結果の使用

const fastPromise = new Promise((resolve) => setTimeout(resolve, 1000, "Fast"));
const slowPromise = new Promise((resolve) => setTimeout(resolve, 2000, "Slow"));

Promise.race([fastPromise, slowPromise])
.then((result) => {
console.log(result); // Fast
})
.catch((error) => {
console.error("Error:", error);
});

非同期イテレーション

for await...ofループを使うと、非同期イテラブルオブジェクトを逐次処理できます。
これは、非同期関数から返されたプロミスを順次処理する場合に便利です。

例: 非同期イテレーション

async function* asyncGenerator() {
yield await new Promise((resolve) => setTimeout(resolve, 1000, "First"));
yield await new Promise((resolve) => setTimeout(resolve, 2000, "Second"));
yield await new Promise((resolve) => setTimeout(resolve, 3000, "Third"));
}

(async () => {
for await (const value of asyncGenerator()) {
console.log(value);
}
})();
// First
// (1秒後)
// Second
// (2秒後)
// Third
// (3秒後)

非同期処理のパフォーマンスと注意点

非同期処理を設計する際には、パフォーマンスやリソース管理に注意を払う必要があります。
特に、多数の非同期操作が発生する場合や、ネットワークの状態が不安定な場合には、
エラーハンドリングやリトライロジックを適切に実装することが重要です。

適切なエラーハンドリング

非同期処理では、エラーハンドリングが重要です。
エラーが発生した際に適切な対応を行わないと、
アプリケーションの安定性に影響を与える可能性があります。

メモリリークの防止

プロミスが適切に解決されない場合や、不要になった非同期処理をキャンセルせずに放置すると、
メモリリークが発生する可能性があります。
これを防ぐために、キャンセル可能なプロミスを設計したり、リソースの解放を適切に行うことが必要です。

まとめ

JavaScriptにおける非同期処理は、
アプリケーションの応答性とユーザー体験を向上させるために不可欠です。
プロミスやasync/awaitを活用することで、複雑な非同期操作を簡潔に実装し、
エラーハンドリングや並列処理を効率的に行うことができます。
適切な非同期処理の設計パターンを採用し、パフォーマンスやリソース管理にも注意を払うことで、
より堅牢で効率的なアプリケーションを開発することが可能です。