目次
コールバック関数とは何か
定義
「他の関数に引数として渡され、後から呼び出される関数」 がコールバックです。
例(同期):
function repeat(n, action) {
for (let i = 0; i < n; i++) {
action(i); // ← 渡されたコールバックを呼んでいる
}
}
repeat(3, (i) => {
console.log("回数:", i);
});ここで、
actionがコールバック関数repeatが「コールバックを受け取って実行する関数」
ポイント:
- コールバック自体は「同期」「非同期」を問わない一般概念
- ただし「コールバック」と聞いたとき、多くの場合は 非同期コールバック の文脈を指すことが多いです
非同期コールバックの例
setTimeout
console.log("A");
setTimeout(() => {
console.log("timeout");
}, 1000);
console.log("B");setTimeout(callback, delay)に渡した関数が、「delayミリ秒後に」呼ばれる- イベントループ的には「delay後にタスクキューへコールバックが積まれる」
出力順:
A
B
(約1秒後)
timeoutDOM イベントハンドラ
button.addEventListener("click", () => {
console.log("clicked!");
});- クリックされたタイミングでコールバックが呼び出される
Node.jsに多い「エラーファーストコールバック」
Node.js の古い(コールバックベースの)APIでよく見るパターン:
fs.readFile("data.txt", "utf8", (err, data) => {
if (err) {
console.error("読み込み失敗:", err);
return;
}
console.log("読み込み成功:", data);
});この (err, data) => { ... } 型を エラーファーストコールバック と呼びます。
ルール:
- 第1引数:エラー(エラーがなければ
nullやundefined) - 第2引数以降:成功時の結果
メリット:
- エラーと成功結果が1つのコールバックにまとまる
デメリット:
- ネストが深くなると、
if (err)だらけになりがち - 途中でエラーが起こったときに「どこで処理済みにしたか」が見えにくい
コールバックの問題点(なぜ Promise が必要になったか)
コールバック地獄
複数の非同期処理を「順番に」行いたいとき、素直に書くとこうなりがちです:
doA((err, resultA) => {
if (err) { handleError(err); return; }
doB(resultA, (err, resultB) => {
if (err) { handleError(err); return; }
doC(resultB, (err, resultC) => {
if (err) { handleError(err); return; }
doD(resultC, (err, resultD) => {
if (err) { handleError(err); return; }
console.log("最終結果:", resultD);
});
});
});
});問題点:
- インデントが右にずれてネストが深くなる
- エラー処理のブロックが何度も出てきて、見通しが悪くなる
- 途中で「キャンセルしたい」「途中の結果をキャッシュしたい」などの要件が来ると、メンテナンスが大変
エラーハンドリングの難しさ
- あるコールバックで例外が投げられた場合、どこまで伝播するのか分かりにくい
- try/catch で囲んでも、そのスコープ外で発生する非同期エラーは捕まえられない
→ コールバック内でthrowしても、「コールバックの実行時点にはもうtryが終わっている」からです。
try {
setTimeout(() => {
throw new Error("これはcatchされない");
}, 0);
} catch (e) {
console.log("ここには来ない");
}制御フローが分かりづらい
- 並列実行(「AとBを同時にやって、両方終わったらC」など)をコールバックで書くと、制御フローがとにかく複雑
- タイムアウト、リトライ、キャンセルなどの高度なロジックも、コールバックだけで綺麗に書くのは難しい
Promise は、これらの問題を「ある程度」解決するために導入された仕組みです。
Promise の基本概念
Promise の「状態」
Promise は「非同期処理の最終結果を表すオブジェクト」で、
必ず次のいずれかの状態を取ります。
pending:保留中(まだ結果が出ていない)fulfilled:成功(値を持って完了)rejected:失敗(理由(エラー)を持って完了)
状態は 一方向にしか進まない:
pending → fulfilledpending → rejected
一度 fulfilled または rejected になったら、それ以降は変わらない。
Promise オブジェクトの作り方
基本形:
const p = new Promise((resolve, reject) => {
// ここが「executor(実行関数)」
// 非同期処理をここに書く
// 成功時
resolve(value);
// 失敗時
reject(error);
});単純な例(1秒後に成功するPromise):
const p = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("OK");
}, 1000);
});
p.then((value) => {
console.log("成功:", value); // "成功: OK"
}).catch((err) => {
console.error("失敗:", err);
});then / catch / finally
p.then(onFulfilled, onRejected?)- 成功時:
onFulfilled(value) - 失敗時:第2引数の
onRejected(reason)(あまり使われず、catchに任せることが多い)
- 成功時:
p.catch(onRejected)- 上流のエラー(reject や throw)をまとめて受ける
p.finally(onFinally)- 成功・失敗どちらでも必ず呼ばれる
- ローディングUIを消したり、リソース解放などに便利
doAsync()
.then(result => {
console.log("成功:", result);
})
.catch(err => {
console.error("エラー:", err);
})
.finally(() => {
console.log("完了(成功・失敗どちらでも)");
});Promise チェーンの挙動
then は「新しい Promise を返す」
const p = doAsync(); // Promise
const p2 = p.then(result => {
return result * 2;
});
p2.then(value => {
console.log(value);
});thenの戻り値p2は 常に新しい Promisethenのコールバックが返した値が、次の Promise の結果になる
3パターン押さえておくと楽です:
- 値を返す
.then(value => value * 2)→ その値でfulfilledされた Promise を返す - Promise を返す
.then(value => otherAsync(value))→ その Promise が落ち着く(fulfill/reject)まで、チェーン全体がそれに追従する - 例外を投げる or
throwする.then(value => { if (value < 0) { throw new Error("負の値はNG"); } return value; })→ 即座に reject 状態の Promise を返す(catchで拾える)
エラー伝播のイメージ
doAsync1()
.then(result1 => {
return doAsync2(result1);
})
.then(result2 => {
return doAsync3(result2);
})
.then(result3 => {
console.log("全部成功:", result3);
})
.catch(err => {
console.error("どこかでエラー:", err);
});doAsync1,doAsync2,doAsync3のどこかでrejectorthrowされたら、- そこから先の
.thenはスキップされ、一番近いcatchに飛んでくる
これが「コールバック地獄」から解放されるポイントです。
コールバックスタイルを Promise に変換する
例:setTimeout をPromise化
function delay(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
}
delay(1000).then(() => {
console.log("1秒経過");
});- コールバックを受け取る
setTimeoutを、
「msミリ秒後に resolve される Promise」としてラップしている - これで
delay(1000)を.then/awaitで扱えるようになる
エラーファーストコールバックをPromise化
Node 風のAPI:
function readFileCb(path, callback) {
// callback(err, data)
}これを Promise で包む:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFileCb(path, (err, data) => {
if (err) {
reject(err); // 失敗 → reject
} else {
resolve(data); // 成功 → resolve
}
});
});
}使い方:
readFilePromise("data.txt")
.then(data => {
console.log("成功:", data);
})
.catch(err => {
console.error("エラー:", err);
});- コールバックスタイルのAPIを Promise 化することを promisify と呼ぶ
- Node.js 公式には
util.promisifyというユーティリティ関数もある
コールバック vs Promise
新規コードを書くなら基本 Promise / async/await
- 非同期処理の読みやすさ・エラーハンドリングの楽さを考えると、今から新しく書く場合はほぼ Promise(+ async/await)一択です
- コールバックはイベントハンドラ(DOMの
addEventListener)などで自然に使われる場面だけに絞ると良い
既存のコールバックAPIに遭遇したら
- まずは そのままコールバックで使っても問題ないかを考える
- 単発で使うだけなら、そのままでもOK
- 何度も使ったり、他の Promise ベースの処理と組み合わせたくなったら、
→ さきほどのように ラッパー(promisify関数) を作る
イベントは「ストリーム」、Promiseは「一回きり」
- ボタンクリックなど 何度も起こるもの → イベントリスナー(コールバック)が本職
- HTTP リクエスト結果など 一度きり の非同期 → Promise/async が本職
この区別を意識しておくと、設計がスッキリします。
コメント