はじめに
先日、複数のAPIを順番に呼び出す処理を実装していた際に、処理の順序が意図したとおりに動かないという問題に遭遇しました。非同期処理の理解が不十分だったために、データの取得と表示のタイミングがずれてしまい、いろいろな記事を参考にしながらむりくり対応したのですが、自分の中で再現性をもって理解できていないと感じたので、改めてブログをまとめながら理解を深めてみました。この記事では、JavaScriptの非同期処理についての進化の過程を追うことで、基礎から実践的なユースケースまでを解説していきたいと思います。
で、まず最初に結論から書いてしまうと、下記のMDNの説明が最も正確ですしヌケモレもなく理解しやすいです。ただ、一点すごく長い。
https://developer.mozilla.org/ja/docs/Learn/JavaScript/Asynchronous/Introducing
なので、少しはまとまっているのでまずはこちらのブログを読んでもらい、時間があるときに上記の公式を読んでもらうのがよいと思います。
対象
この記事は以下のような方を対象としています:
- JavaScriptの基本文法は理解している
- 関数やオブジェクトの扱いには慣れている
- 非同期処理に苦手意識がある、もしくは深く理解したい
ハマった事象と応急処置
問題の概要
下記の要件を、簡易的なコードで表しています。
- ユーザー情報を取得する
- 取得したユーザーIDを使って、そのユーザーの投稿を取得する
- 投稿を画面に表示する
function getUserData() {
// APIからユーザーデータを取得する処理
setTimeout(() => {
console.log('非同期処理1');
return { id: 1, name: "John" };
}, 1000);
}
function getPosts(userId) {
// ユーザーの投稿を取得する処理
setTimeout(() => {
console.log('非同期処理2': userId);
return [
{ id: 1, title: "Post 1" },
{ id: 2, title: "Post 2" }
];
}, 1000);
}
// 問題のあるコード
function showUserPosts() {
const user = getUserData(); //
const posts = getPosts(user.id); //
console.log('同期処理3:', posts); // → undefined と出力される
}
showUserPosts();
※[補足]setTimeout()・・・指定された時間(ミリ秒)後に内部に記載された関数を実行する
setTimeout(() => {
console.log("1 秒待ちました。");
}, 1000); //1000ミリ秒待ってからconsole.logを実行する
問題点
このコードを実行すると、以下のように出力されます:
同期処理3: undefined
非同期処理1
非同期処理2: undefined
このコードで起きた問題
-
getUserData()
の中で実行されるsetTimeoutは非同期で実行されるため処理が後回しとなる - 続けて、
getPosts()
を呼び出すが引数はundefinedとなっている上に、同じく非同期処理となるため処理が後回しとなる -
console.log()
は、同期処理なので最初に実行されるが変数がまだ定義されておらずundefinedと出力されてしまう
これは最初の2つの関数が内部で非同期の処理をしているにもかかわらずそれを意識した実装になっていなかったことで、このような状況に陥っていました。
そもそもなぜ非同期処理するのか
同期的な処理の場合、コードは単純に上から下へ実行されます。
シンプルでわかりやすい反面、処理に時間がかかるものを実行する場合、処理の結果を待つ間、処理結果が返ってくるまでずっと待ち状態となり、ユーザーの見え方として画面が固まって(フリーズして)いるような状況になってしまいます。
そのため、データベースアクセスや外部APIからのデータ取得などの処理においては、先に他の処理に移行してもらいつつ裏側で処理を継続させる実装(=非同期処理を用いた実装)となっています。
そのため、プログラマーが非同期の処理を使いたい使いたくない関係なく、非同期処理を意識した実装をする必要があります。
非同期関数の例
setTimeout
setInterval
fetch
DOMのイベント処理(addEventListener)
requestAnimationFrame
I/O処理(ファイル・DB等)
Promiseの.then()/.catch()/.finally()
queueMicrotask()
MutationObserver
※厳密には非同期の関数は、更にマイクロタスク関数とマクロタスク関数とにわかれ、優先順位も異なりますが、その辺りは別途取りまとめたいと思います。
非同期の処理のバリエーション
非同期処理にはどのようなバリエーションがあるのか見て行きます。
記法の進化の流れに沿って、理解していったことで自分は理解が進んだので、同じように古い順に説明していこうと思います。
コールバックの場合
まずはシンプルなコールバック関数を利用した例から見てみましょう。
function getUserData(callback) {
setTimeout(() => {
const user = { id: 1, name: "John" };
callback(user);
}, 1000);
}
function getPosts(userId, callback) {
setTimeout(() => {
const posts = [
{ id: 1, title: "Post 1" },
{ id: 2, title: "Post 2" }
];
callback(posts);
}, 1000);
}
function showUserPosts() {
getUserData((user) => {
console.log('ユーザー:', user);
getPosts(user.id, (posts) => {
console.log('投稿:', posts);
});
});
}
showUserPosts();
// 1秒後に出力: ユーザー: { id: 1, name: "John" }
// さらに1秒後に出力: 投稿: [{ id: 1, title: "Post 1" }, { id: 2, title: "Post 2" }]
引数にCallbackという名前で関数を受け取り、関数内部のcallback()
の記述で引数に渡された関数が実行されるという実装になります。関数の引数に関数を渡せることを知らず最初は理解に手間取りましたが、順を追ってコードを追っていくことで理解ができます。
ですが、連続して実行される非同期処理が増えていくと、どんどん処理がネスト(入れ子)になっていきわかりにくくなっていきます。(コールバック地獄というらしい)
//コールバック地獄の例
//各投稿に対してコメントを取得する処理を追加すると...
function getComments(postId, callback) {
setTimeout(() => {
const comments = [
{ id: 1, text: "Comment 1" },
{ id: 2, text: "Comment 2" }
];
callback(comments);
}, 1000);
}
function showUserPostsWithComments() {
getUserData((user) => {
console.log('ユーザー:', user);
getPosts(user.id, (posts) => {
console.log('投稿を取得:', posts);
// 各投稿のコメントを取得
posts.forEach((post) => {
getComments(post.id, (comments) => {
console.log(`投稿${post.id}のコメント:`, comments);
// さらにコメントに対する返信を取得する場合...
comments.forEach((comment) => {
getReplies(comment.id, (replies) => {
console.log(`コメント${comment.id}の返信:`, replies);
// さらにその返信への反応を取得する場合...
replies.forEach((reply) => {
getReactions(reply.id, (reactions) => {
console.log(`返信${reply.id}のリアクション:`, reactions);
// ネストが深くなりすぎて見づらい...
// エラーハンドリングを入れるとさらに複雑に...
});
});
});
});
});
});
});
});
}
// getRepliesとgetReactionsの実装
function getReplies(commentId, callback) {
setTimeout(() => {
callback([
{ id: 1, text: "Reply 1" },
{ id: 2, text: "Reply 2" }
]);
}, 1000);
}
function getReactions(replyId, callback) {
setTimeout(() => {
callback([
{ id: 1, type: "Good" },
{ id: 2, type: "Love" }
]);
}, 1000);
}
showUserPostsWithComments();
ネストが深くなるほどコードの見通しが悪くなりますね。
本番で利用する場合は、エラーハンドリングも各層で必要になるので、さらに複雑化していきそうです。
Promiseの場合
Promiseを使うと、コールバックのネストを平坦にできます。
ちなみに、Promiseは、成功した場合の処理に遷移するためのresolve
と、失敗した場合用のreject
をコールバックの引数として受け取ります。
先ほどのコールバック地獄のコードをPromiseで書き直すと、以下のようになります。
function getComments(postId) {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, text: "Comment 1" },
{ id: 2, text: "Comment 2" }
]);
}, 1000);
});
}
function getReplies(commentId) {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, text: "Reply 1" },
{ id: 2, text: "Reply 2" }
]);
}, 1000);
});
}
function showUserPostsWithComments() {
getUserData()
.then(user => {
console.log('ユーザー:', user);
return getPosts(user.id);
})
.then(posts => {
console.log('投稿を取得:', posts);
return getComments(posts[0].id);
})
.then(comments => {
console.log('コメントを取得:', comments);
return getReplies(comments[0].id);
})
.then(replies => {
console.log('返信を取得:', replies);
});
}
このようにPromiseを使うことで、コールバックのネストを平坦化でき、処理の流れが分かりやすくなります。
Promiseのチェーン処理においては、戻り値にPromiseのインスタンスを返すことが重要です。(return getReplies(comments[0].id);の部分)これを分けて書くと、非同期のチェーンが切断されてしまい、思い通りに動かなくなります。
以下、非同期の処理チェーンが切断されてしまう例を見ていきます。
function showUserPostsWithCommentsBroken() {
getUserData()
.then(user => {
console.log('ユーザー:', user);
getPosts(user.id); // getPostsの戻り値であるPromiseインスタンスをreturnしていない
})
.then(posts => {
// postsはundefinedになる
console.log('投稿:', posts);
getComments(posts.id); // エラーになる(undefined.id)
})
.then(comments => {
// この処理は実行されない
console.log('コメント:', comments);
});
}
// 分かりやすい例:非同期処理のチェーンが切れる
function processUserData() {
return getUserData()
.then(user => {
// 非同期処理の結果をreturnし忘れているパターン1
validateUser(user); // Promiseを返す関数だが、returnしていない
})
.then(validatedUser => {
// validatedUserはundefined
return getPosts(validatedUser.id); // エラーになる(undefined.idを参照しようとする)
});
}
// 正しい実装
function getCorrectUserPosts() {
return getUserData()
.then(user => {
console.log('ユーザー:', user);
// Promiseをreturnすることで、チェーンが途切れない
return getPosts(user.id);
})
.then(posts => {
console.log('投稿:', posts);
// さらにチェーンする場合は、ここでも適切にreturnする
return posts;
});
}
// 補助関数
function validateUser(user) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ...user, validated: true });
}, 1000);
});
}
function someAsyncOperation(user) {
return new Promise((resolve) => {
setTimeout(() => {
console.log('非同期処理が完了しました');
resolve(true);
}, 1000);
});
}
ちなみに、Promiseを用いた非同期処理のチェーンの書き方は、いろいろなパターンが存在します。
このあたりは、非同期処理がなんとなく理解できたら、「Javascript Promise パターン」などで調べてもらうとより理解が深まると思います。
Promiseについての公式のページもかなり参考になるので、リンクを張っておきます。
MDN Promise
async/awaitの場合
関数の宣言でasync、関数の実行でawaitを付ることで、非同期処理を同期処理のように書くことができます。
asyncを宣言した関数は内部的には戻り値をPromiseのインスタンスとして返します。それをawaitで受けることで、返されたPromiseが履行されるか拒否されるまで実行(左辺への代入)を中断します。それにより、Promiseを返す非同期の関数をあたかも同期処理のように動作させます。
async function showUserPosts() {
const user = await getUserData();
console.log('ユーザー:', user);
const posts = await getPosts(user.id);
console.log('投稿:', posts);
}
showUserPosts();
// 1秒後に出力: ユーザー: { id: 1, name: "John" }
// さらに1秒後に出力: 投稿: [{ id: 1, title: "Post 1" }, { id: 2, title: "Post 2" }]
並列処理
非同期の処理を順番に実行していく場合は、Promiseかasync/awaitを使って非同期のチェーンを作ればよいのですが、処理時間短縮のために同時並行で実行したい場合は、並行処理を行います。
Promise.all
// Promise.allを使用した並列処理
async function getUserContents(userId) {
console.log('データ取得開始');
const [posts, comments] = await Promise.all([
getPosts(userId),
getComments(userId)
]);
console.log('投稿:', posts);
console.log('コメント:', comments);
}
Promise.allの場合、並列している処理の一つでもReject(処理エラー)となるとすべての処理が、Rejectの扱いになります。
Promise.allSettled
// エラーを無視して処理を継続する場合
async function getUserContentsSettled(userId) {
const results = await Promise.allSettled([
getPosts(userId),
getComments(userId)
]);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('成功:', result.value);
} else {
console.log('失敗:', result.reason);
}
});
}
Promise.allの場合、並列している処理のいずれかでreject(処理エラー)となっても一つでもresolve(処理成功)であれば、全体としてresolve(処理成功)の扱いになります。
エラーハンドリング
それぞれの方式の実装パターン事に、エラーハンドリングの書き方も変わります。
非同期処理するならエラーハンドリングは必ず必要と言われる部分ではありますが、今回はまとめきれていないので、別途まとめて行こうと思います。
まとめ
非同期処理の種類と特徴
-
コールバック
- メリット:シンプルで理解しやすい
- デメリット:ネストが深くなりがち、エラーハンドリングが複雑
-
Promise
- メリット:コードの平坦化、エラーハンドリングの統一
- デメリット:やや冗長な書き方になる
-
async/await
- メリット:同期処理のように書ける、読みやすい
- デメリット:関数が内部でPromiseインスタンスを返すことを意識する必要がある
4. Promise.all 又は Promise.allSettled
- メリット:複数の非同期処理を並行処理することで処理時間を短縮できる
- デメリット:並行処理の一部がエラーとなった場合の制御を意識する必要がある
使い分けのポイント
- シンプルなイベントハンドリング → コールバック
- 複数の非同期処理の連携 → Promise または async/await
- エラーハンドリングが重要な場合 → Promise または async/await
- 並列処理が必要な場合 → Promise.all または Promise.allSettled
非同期処理については扱いが難しく苦手意識を持っていましたが、上記のように基本的なパターンや扱う上での注意点を整理したことで、陥りやすいミスを避け適切に実装できるようになりました。
非同期処理に苦手意識を持っている方の理解に少しでも貢献できたら嬉しいです。
今後更に非同期処理への理解を深めるために、Promiseの実装パターンやそれに伴うエラーハンドリング実装をまとめたり、処理順序制御を理解するためにJavascriptにおけるイベントループについて深堀って記述してみたいと思います。