【JavaScript】コールバック関数とは?非同期処理の入り口

javascript-icon JavaScript

こんにちは、かつコーチです。

前回はthisの挙動について解説しました。

今回は、非同期処理を理解するための最初の一歩となる「コールバック関数」を扱います。

setTimeoutaddEventListenerを使ったことがあれば、実は知らないうちにコールバック関数を使っています。

「なんとなく動くから使っていた」という人も多いテーマだと思うので、今日はその仕組みをしっかり言語化していきます。

コールバック関数とは?

「関数を引数として渡す」という考え方

コールバック関数とは、他の関数に「引数」として渡され、その関数の中で呼び出される関数のことです。

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側に委ねているイメージです。

コールバックの基本パターン

同期的なコールバック

すべてのコールバックが「後で呼ばれる」わけではありません。

配列操作でよく使うforEachmapのコールバックは、その場ですぐに(同期的に)実行されます。

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ならエラーなし、何か値が入っていればエラーあり、というルールにすることで、コールバックを呼び出す側とされる側で「エラーの扱い方」を統一できます。

この考え方は、後で学ぶPromiserejecttry/catchにもつながっていく重要な発想です。

コールバックを使うべき場面・使わない方がよい場面

現在の実務では、非同期処理はPromiseasync/awaitで書くのが主流です。

とはいえ、コールバック自体は今も現役で使われています。

場面適した書き方
forEach / map / addEventListenerなど同期的な処理コールバック関数(そのままでOK)
通信処理・タイマーなど1回きりの非同期処理Promise / async await
複数の非同期処理を順番につなげたい場合Promise / async await(コールバックはネストが深くなるため避ける)

「同期処理の中で関数を渡す」ときはコールバックのままで問題ありませんが、「非同期処理を連続させたい」ときはコールバック地獄を避けるためにもPromiseベースの書き方を選びましょう。

まとめ

この記事のポイント

  • コールバック関数とは「他の関数に引数として渡され、その中で呼び出される関数」
  • forEachなどの同期的なコールバックと、setTimeoutなどの非同期的なコールバックがある
  • 非同期処理はコードに書いた順番どおりに実行されるとは限らない
  • 非同期処理を連続させるとコールバック地獄(ネストの深さ)が起きやすい
  • コールバック地獄の解消策としてPromiseが生まれた

次に読むべき記事

コールバックの仕組みが分かったところで、次はコールバック地獄を解消するPromiseの基本を学んでいきましょう。

→ 次の記事:Promiseの基本

タイトルとURLをコピーしました