JavaScriptは基本的にシングルスレッドで動作します。つまり、一度に一つの処理しか実行できません。しかし、ネットワークリクエストやタイマーなどの重い処理を行っている間、ブラウザがフリーズしたりサーバーが応答しなくなったりしては困ります。
そこでJavaScriptは、重い処理をバックグラウンド(Web APIsやNode.jsのC++レイヤー)に任せ、完了通知を受け取ることで並行処理のような動きを実現しています。
本章では、JavaScriptの非同期処理の基盤となるメカニズムと、それを現代的に扱うための標準APIである Promise について解説します。
まず、挙動の違いを確認しましょう。
以下のコードは、setTimeout(非同期API)を使用した例です。他言語の経験者であれば、「Start」→「1秒待機」→「Timer」→「End」と予想するかもしれませんが、JavaScriptでは異なります。
console.log('1. Start');
// 1000ミリ秒後にコールバックを実行する非同期関数
setTimeout(() => {
console.log('2. Timer fired');
}, 1000);
console.log('3. End');node async_demo.js1. Start 3. End 2. Timer fired
setTimeout は「タイマーをセットする」という命令だけを出し、即座に制御を返します。そのため、タイマーの発火を待たずに 3. End が出力されます。
なぜシングルスレッドで非同期処理が可能なのか、その裏側にあるのが イベントループ (Event Loop) という仕組みです。
JavaScriptのランタイムは主に以下の要素で構成されています:
処理の流れ:
setTimeout がコールスタックで実行されると、ブラウザのタイマーAPIに処理を依頼し、スタックから消えます。この仕組みにより、メインスレッドをブロックすることなく非同期処理を実現しています。
Promiseが登場する以前(ES5時代まで)は、非同期処理の順序制御を行うために、コールバック関数を入れ子にする手法が一般的でした。
例えば、「処理Aが終わったら処理B、その後に処理C...」というコードを書こうとすると、以下のようにネストが深くなります。
function delay(ms, callback) {
setTimeout(callback, ms);
}
console.log('Start');
delay(1000, () => {
console.log('Step 1 finished');
delay(1000, () => {
console.log('Step 2 finished');
delay(1000, () => {
console.log('Step 3 finished');
console.log('End');
});
});
});node callback_hell.jsStart Step 1 finished Step 2 finished Step 3 finished End
これはいわゆる 「コールバック地獄 (Callback Hell)」 と呼ばれる状態で、可読性が低く、エラーハンドリングも困難です。これを解決するために導入されたのが Promise です。
Promise は、非同期処理の「最終的な完了(または失敗)」とその「結果の値」を表すオブジェクトです。未来のある時点で値が返ってくる「約束手形」のようなものと考えてください。
Promiseオブジェクトは以下の3つの状態のいずれかを持ちます。
resolve された)reject された)Promiseの状態は一度 Pending から Fulfilled または Rejected に変化すると、二度と変化しません(Immutable)。
new Promise コンストラクタを使用します。引数には (resolve, reject) を受け取る関数(Executor)を渡します。
> const myPromise = new Promise((resolve, reject) => {
... // ここで非同期処理を行う
... const success = true;
... if (success) {
... resolve("OK!"); // 成功時
... } else {
... reject(new Error("Failed")); // 失敗時
... }
... });
undefined
> myPromise
Promise { 'OK!' }
Promiseオブジェクトの結果を受け取るには、以下のメソッドを使用します。
.then(onFulfilled): PromiseがFulfilledになった時に実行されます。.catch(onRejected): PromiseがRejectedになった時に実行されます。.finally(onFinally): 成功・失敗に関わらず、処理終了時に実行されます。先ほどのコールバック地獄の例を、Promiseを使って書き直してみましょう。
// Promiseを返す関数を作成
function delay(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Waited ${ms}ms`);
}, ms);
});
}
console.log('Start');
delay(1000)
.then((message) => {
console.log('Step 1:', message);
// 次のPromiseを返すことでチェーンをつなぐ
return delay(1000);
})
.then((message) => {
console.log('Step 2:', message);
return delay(1000);
})
.then((message) => {
console.log('Step 3:', message);
console.log('End');
})
.catch((error) => {
// チェーンのどこかでエラーが起きればここに飛ぶ
console.error('Error:', error);
});node promise_chain.jsStart Step 1: Waited 1000ms Step 2: Waited 1000ms Step 3: Waited 1000ms End
重要なポイント:
.then() の中で新しい Promise を返すと、次の .then() はその新しい Promise の完了を待ちます。これにより、非同期処理を フラットな連鎖 として記述できます。.catch() に集約できます。try-catch ブロックに近い感覚で扱えるようになります。.then() をチェーンさせることで、非同期処理を直列に、読みやすく記述できます。.catch() で一括して行えます。次章では、このPromiseをさらに同期処理のように書ける構文糖衣 async/await について学びます。
Math.random() を使い、50%の確率で成功(Resolve)、50%の確率で失敗(Reject)するPromiseを返す関数 coinToss を作成してください。
それを使用し、成功時は "Win!"、失敗時は "Lose..." とコンソールに表示するコードを書いてください。
node practice9_1.js以下の仕様を満たすコードを作成してください。
fetchUser(userId): 1秒後に { id: userId, name: "User" + userId } というオブジェクトでresolveする。fetchPosts(userName): 1秒後に ["Post 1 by " + userName, "Post 2 by " + userName] という配列でresolveする。1 でユーザーを取得した後、その名前を使って投稿を取得し、最終的に投稿リストをコンソールに表示してください。node practice9_2.js