[JavaScript講座] コールバックとPromise

当ページのリンクには広告が含まれています。
目次

コールバック関数とは何か

定義

「他の関数に引数として渡され、後から呼び出される関数」 がコールバックです。

例(同期):

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秒後)
timeout

DOM イベントハンドラ

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引数:エラー(エラーがなければ nullundefined
  • 第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 は「非同期処理の最終結果を表すオブジェクト」で、
必ず次のいずれかの状態を取ります。

  1. pending:保留中(まだ結果が出ていない)
  2. fulfilled:成功(値を持って完了)
  3. rejected:失敗(理由(エラー)を持って完了)

状態は 一方向にしか進まない:

  • pending → fulfilled
  • pending → 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 は 常に新しい Promise
  • then のコールバックが返した値が、次の Promise の結果になる

3パターン押さえておくと楽です:

  1. 値を返す .then(value => value * 2) → その値で fulfilled された Promise を返す
  2. Promise を返す .then(value => otherAsync(value)) → その Promise が落ち着く(fulfill/reject)まで、チェーン全体がそれに追従する
  3. 例外を投げる 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 のどこかで reject or throw されたら、
  • そこから先の .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 が本職

この区別を意識しておくと、設計がスッキリします。


<<前へ(JavaScriptの実行モデルとマイクロタスク)

>>次へ(async / await)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次