こんにちは、かつコーチです。
前回はthisの挙動について解説しました。
今回は、非同期処理を理解するための最初の一歩となる「コールバック関数」を扱います。
setTimeoutやaddEventListenerを使ったことがあれば、実は知らないうちにコールバック関数を使っています。
「なんとなく動くから使っていた」という人も多いテーマだと思うので、今日はその仕組みをしっかり言語化していきます。
コールバック関数とは?
「関数を引数として渡す」という考え方
コールバック関数とは、他の関数に「引数」として渡され、その関数の中で呼び出される関数のことです。
JavaScriptでは関数を変数のように扱えるため、関数を別の関数の引数にすることができます。
function greet(name, callback) {
const message = `こんにちは、${name}さん`;
callback(message); // 渡された関数をここで呼び出す
}
function showMessage(message) {
console.log(message);
}
greet("かつコーチ", showMessage); // こんにちは、かつコーチさん
greet関数にshowMessageという関数そのものを渡し、greetの中でcallback(message)として呼び出しています。
「関数の中に処理をお願いする関数を渡す」——これがコールバックの基本の形です。
なぜコールバックという考え方が必要なのか
JavaScriptには、「時間がかかる処理」がたくさんあります。
- サーバーからデータを取得する通信処理
- 一定時間待ってから実行する処理(
setTimeout) - ファイルの読み込み
これらの処理は「いつ終わるか分からない」ため、処理が終わった後にやりたいことを、あらかじめ関数として渡しておく必要があります。
setTimeout(function () {
console.log("3秒後に実行されます");
}, 3000);
setTimeoutに渡した無名関数が、まさにコールバック関数です。
「3秒経ったら、この関数を呼び出してね」と、処理の実行タイミングをJavaScript側に委ねているイメージです。
コールバックの基本パターン
同期的なコールバック
すべてのコールバックが「後で呼ばれる」わけではありません。
配列操作でよく使うforEachやmapのコールバックは、その場ですぐに(同期的に)実行されます。
const numbers = [1, 2, 3];
numbers.forEach(function (num) {
console.log(num * 2);
});
// 2
// 4
// 6
forEachは配列の要素を1つずつ取り出し、その都度渡されたコールバック関数を呼び出しています。
このタイプのコールバックは「非同期処理」ではなく、単に「処理を関数として渡して使い回す」ためのものです。
非同期的なコールバック
一方、setTimeoutや通信処理のコールバックは、呼び出されるタイミングが未来のどこかという点が異なります。
console.log("1: 開始");
setTimeout(function () {
console.log("2: 3秒後に実行");
}, 3000);
console.log("3: 終了");
// 出力順
// 1: 開始
// 3: 終了
// 2: 3秒後に実行
コードの見た目は上から順に並んでいますが、実際の実行順序は1 → 3 → 2になります。
setTimeoutは「3秒後にコールバックを呼んでね」とブラウザ(正確にはJavaScriptの実行環境)に予約するだけで、その場では処理を待たずに次の行に進んでしまうためです。
私は最初この挙動を知らず、「なぜconsole.logの順番が想定と違うんだろう」と真剣に悩んだことがあります。
「非同期処理は、書いた順番どおりに実行されるとは限らない」——これはJavaScriptを学ぶ上で必ず理解しておくべき前提です。
コールバック地獄というつまずきポイント
実際につまずいた「ネストが深くなる」問題
非同期処理を「Aが終わったらB、Bが終わったらC」という順番で実行したいとき、コールバックを使うとどうしてもネスト(入れ子)が深くなります。
❌ Before:コールバックが何重にもネストして読みにくい
function fetchUser(callback) {
setTimeout(() => {
console.log("ユーザー情報を取得");
callback({ id: 1, name: "かつコーチ" });
}, 1000);
}
function fetchPosts(user, callback) {
setTimeout(() => {
console.log(`${user.name}の投稿を取得`);
callback(["記事A", "記事B"]);
}, 1000);
}
function fetchComments(post, callback) {
setTimeout(() => {
console.log(`${post}のコメントを取得`);
callback(["コメント1", "コメント2"]);
}, 1000);
}
// 「取得できたら次を取得」を繰り返すとどんどん右にネストしていく
fetchUser(function (user) {
fetchPosts(user, function (posts) {
fetchComments(posts[0], function (comments) {
console.log(comments);
});
});
});
私が業務で似たコードに遭遇したとき、処理が右にどんどんインデントされていき、「今どの階層のコールバックを見ているのか」を見失いました。
エディタの横スクロールが必要になるレベルまでネストが深くなり、修正するにも「どこに何を足せばいいか」が分かりにくい状態でした。
この現象はコールバック地獄(callback hell)と呼ばれ、非同期処理を扱う上での代表的なつまずきポイントです。
✅ After:Promiseで平らな形に書き直す
function fetchUser() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("ユーザー情報を取得");
resolve({ id: 1, name: "かつコーチ" });
}, 1000);
});
}
function fetchPosts(user) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${user.name}の投稿を取得`);
resolve(["記事A", "記事B"]);
}, 1000);
});
}
function fetchComments(post) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${post}のコメントを取得`);
resolve(["コメント1", "コメント2"]);
}, 1000);
});
}
// .thenをつなげることで、ネストせず横一列に処理を書ける
fetchUser()
.then((user) => fetchPosts(user))
.then((posts) => fetchComments(posts[0]))
.then((comments) => console.log(comments));
Promiseという仕組みを使うと、.then()を数珠つなぎにするだけで、ネストせずに「順番に処理する」流れを表現できます。
Promiseの詳しい使い方は次回の記事でじっくり解説しますが、「コールバック地獄を解消する手段として生まれた」という背景だけ、ここで押さえておいてください。
応用:エラーハンドリングを考慮したコールバック設計
エラーファーストコールバックという慣習
Node.jsなど、伝統的なJavaScript環境では「コールバックの第1引数にエラー情報を渡す」という慣習がよく使われます。
function fetchData(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("IDは1以上を指定してください"), null);
return;
}
callback(null, { id, name: "サンプルデータ" });
}, 1000);
}
fetchData(1, function (error, data) {
if (error) {
console.error("エラー:", error.message);
return;
}
console.log("取得結果:", data);
});
第1引数がnullならエラーなし、何か値が入っていればエラーあり、というルールにすることで、コールバックを呼び出す側とされる側で「エラーの扱い方」を統一できます。
この考え方は、後で学ぶPromiseのrejectやtry/catchにもつながっていく重要な発想です。
コールバックを使うべき場面・使わない方がよい場面
現在の実務では、非同期処理はPromiseやasync/awaitで書くのが主流です。
とはいえ、コールバック自体は今も現役で使われています。
| 場面 | 適した書き方 |
|---|---|
forEach / map / addEventListenerなど同期的な処理 | コールバック関数(そのままでOK) |
| 通信処理・タイマーなど1回きりの非同期処理 | Promise / async await |
| 複数の非同期処理を順番につなげたい場合 | Promise / async await(コールバックはネストが深くなるため避ける) |
「同期処理の中で関数を渡す」ときはコールバックのままで問題ありませんが、「非同期処理を連続させたい」ときはコールバック地獄を避けるためにもPromiseベースの書き方を選びましょう。
まとめ
この記事のポイント
- コールバック関数とは「他の関数に引数として渡され、その中で呼び出される関数」
forEachなどの同期的なコールバックと、setTimeoutなどの非同期的なコールバックがある- 非同期処理はコードに書いた順番どおりに実行されるとは限らない
- 非同期処理を連続させるとコールバック地獄(ネストの深さ)が起きやすい
- コールバック地獄の解消策として
Promiseが生まれた
次に読むべき記事
コールバックの仕組みが分かったところで、次はコールバック地獄を解消するPromiseの基本を学んでいきましょう。
→ 次の記事:Promiseの基本