Menu
日本語

JavaScriptのコールバック関数とコールバック地獄を理解する

JavaScriptのコールバック関数の仕組みを基礎から解説。関数を引数として渡す方法、エラーファーストパターン、そしてネストが深くなる「コールバック地獄」からPromiseへ移行した経緯まで。

コールバック関数とは:別の関数に渡す関数のこと

JavaScript において関数は「値」として扱えます。変数に入れたり、配列に入れたり、そして今回の本題である「引数として渡す」こともできます。あとで呼び出してもらうために別の関数へ渡すこの関数のことを、コールバック関数と呼びます。

index.js
Output
Click Run to see the output here.

greetformatter が何をしているのか一切気にしません。名前を渡して呼び出し、その結果を使うだけです。どんな振る舞いをさせるかは、渡すコールバックを差し替えるだけで決まります。この柔軟さこそが、コールバックという仕組みが存在する理由です。

同期的に実行されるコールバック

コールバックがすべて非同期とは限りません。普段よく使っている配列メソッドの多くもコールバックを受け取りますが、これらは同期的に、つまり外側の呼び出しが返る前にコールバックを実行します。

index.js
Output
Click Run to see the output here.

mapfilterreduce も、コールバックを受け取ってその場で各要素ごとに 1 回ずつ呼び出します。map が値を返した時点で、コールバックの呼び出しはすべて完了しています。あとで実行するためにキューに積まれるわけではありません。

これはいわゆる高階関数のパターンそのもので、「やってほしい処理はこれ、やり方はこう、結果をちょうだい」という流れです。イベントループは一切関係ありません。

非同期のコールバックは「あとで」実行される

世間で「コールバック」と呼ばれているのは、たいてい非同期のほうです。タイマー、ネットワークリクエスト、ファイル読み込みなど、時間のかかる API に関数を渡しておくと、処理が終わったタイミングでその API が関数を呼び戻してくれる、というパターンですね。

index.js
Output
Click Run to see the output here.

出力は 開始前開始後、そして1秒後に タイマー発火 の順に表示されます。setTimeout は処理を止めているわけではありません。コールバックをランタイムに預けたらすぐに戻り、残りのスクリプトはそのまま動き続けます。1秒後にイベントループがそのコールバックを拾い上げて実行する、という流れです。

この「すぐに返して、あとで呼び戻す」という形こそが、addEventListener から古い Node.js のファイル API に至るまで、JavaScript のあらゆる非同期コールバック API に共通するメンタルモデルです。

エラーファーストコールバック(Node.js の慣習)

Promise が登場する前、Node.js ではコールバックの形を統一するために一つの慣習が採用されていました。第1引数にはエラー(エラーがなければ null)、それ以降の引数に実際の結果を渡す、というスタイルです。古いコードや一部のライブラリでは今でもよく目にします。

index.js
Output
Click Run to see the output here.

呼び出し側はまず err をチェックし、値があれば早期リターンする。問題がなければ result を使う、という流れです。言語仕様で強制されているわけではなく、あくまで慣習ですが、(err, result) => ... というシグネチャを一度覚えてしまえば、あちこちで同じパターンを見かけるはずです。

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

厄介なのは、ある非同期処理の結果を次の非同期処理で使いたいときです。コールバックを前のコールバックの中にネストしていくことになり、コードがどんどん右へ右へと階段状に伸びていきます。

index.js
Output
Click Run to see the output here.

これがいわゆる「破滅のピラミッド」、通称コールバック地獄です。これがつらい理由はいくつかあります。

  • 処理の流れが上から下にまっすぐ読めず、ジグザグに進む。
  • すべての階層で if (err) return ... という決まり文句を書くハメになる。
  • 内側のコールバックで例外が投げられても外側に伝わらないので、層ごとにエラー処理を書く必要がある。
  • リファクタしようとするとブロック全体をインデントし直すことになる。

名前付き関数に切り出せばある程度フラットにできますが、根本的な問題 — 生のコールバックでは非同期処理の組み立てが不格好になるという点 — は消えません。これこそ Promise が解決しようとした課題です。

ハマりやすい落とし穴2つ

うっかりコールバックを呼び出してしまわないこと。 コールバックを渡すときは、関数そのものを渡します。関数を呼び出した結果を渡すのではありません。

index.js
Output
Click Run to see the output here.

this の扱いには注意。 コールバックが this を使う通常の関数だと、this の値は関数を定義した場所ではなく、呼び出され方によって決まります。アロー関数なら外側のスコープの this をそのまま引き継ぐので、こうした厄介ごとを回避できます。

index.js
Output
Click Run to see the output here.

インラインのコールバックで迷わずアロー関数が選ばれるのは、まさにこの理由からです。

コールバックと Promise の違い

コールバック関数は、mapforEachsort のような同期 API や、element.addEventListener("click", ...) のようなイベントリスナー、さらにはランタイムの低レベルなフックなど、今でも至るところで登場します。ただし、単一の結果を返す非同期処理については、エコシステムはほぼ完全に Promise へと移行しました。

ざっくり比較するとこんな感じです。

  • コールバック — シンプルで直接的だが、組み合わせに弱い。エラー処理もステップごとに手動で書く必要がある。
  • Promise — 将来の結果を表す値。.then() でチェーンし、.catch() でエラーをまとめて処理できるので、ピラミッド構造がフラットに解消される。

とはいえ、コールバックの理解は今も必須です。Promise はコールバックの上に成り立っていますし、イベント駆動のコードでは避けて通れません。ただ、新しい非同期 API を素のコールバックで書く機会はほとんどなくなりました。

次は Promise へ

Promise は「準備ができたらこれをやる」という発想を、受け渡したりチェーンしたり組み合わせたりできるオブジェクトとして包んだものです。次のページではこの Promise を扱い、現代の JavaScript で非同期処理の主役となっている async/await への橋渡しをしていきます。

よくある質問

JavaScriptのコールバック関数とは何ですか?

コールバックとは、別の関数に「引数として渡す関数」のことです。渡された側は、あとで好きなタイミングでその関数を呼び出せます。たとえば setTimeout(() => console.log('hi'), 1000) ではアロー関数をコールバックとして渡していて、setTimeout はそれを保持しておき、タイマーが発火したタイミングで呼び出します。「何かが終わったらこれを実行する」という処理を、JavaScriptが最初から使ってきた最もシンプルな方法です。

同期コールバックと非同期コールバックの違いは?

同期コールバックは、受け取った関数がその場で即座に実行されます。たとえば [1, 2, 3].map(x => x * 2) は、map が戻る前にコールバックを3回呼び終えています。一方の非同期コールバックは、いったん保持されて、あとで何らかのイベントが起きたときに呼び出されるもの。setTimeoutfs.readFile、DOMのイベントリスナーなどがこれに該当します。非同期コールバックは後続の処理をブロックしないのがポイントです。

コールバック地獄とは何で、どうすれば避けられますか?

コールバック地獄とは、非同期処理が次の非同期処理に依存する形で重なり、コールバックのネストが何階層にも深くなってピラミッド状のインデントになってしまう状態のことです。処理の流れもエラーハンドリングも追いづらくなります。解決策としては、.then() をチェーンするPromiseへの書き換えか、さらに読みやすくするなら async/await を使うこと。どちらもピラミッドを平坦化して、上から下に読めるコードに戻してくれます。

Coddyでコードを学ぼう

始める