- JavaScript
JavaScriptコールバック関数とは?書き方からPromise置き換えまで
コールバック関数とは、ほかの関数に引数として渡し、渡した先の関数の中で呼び出してもらう関数のことです。 setTimeout(関数, 1000) の「関数」や、配列.map(関数) の「関数」がコールバック関数にあたります。自分で呼び出すのではなく、「このタイミングでこれを実行してください」と相手に預ける、というのがポイントです。
この記事では、コールバック関数の書き方3種類から始めて、同じ処理を「コールバック → コールバック地獄 → Promise → async/await」と4段階で書き換え、各段階の実行結果と実行順を並べて比較します。関数に () を付けて渡してしまうミス、thisが失われる例、エラーファースト形式の落とし穴も、実際のエラーメッセージつきで再現しました。実行結果はすべて Node.js v22.22.2 で実際に動かしたものです。
コールバック関数の早見表
書き方の早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 定義済みの関数を渡す | setTimeout(show, 1000) |
関数名だけを書く。() は付けない |
| 無名関数をその場で渡す | setTimeout(function () { ... }, 1000) |
1回しか使わない処理に向く |
| アロー関数をその場で渡す | setTimeout(() => { ... }, 1000) |
もっともよく使われる形 |
| コールバックに引数を渡したい | setTimeout(() => hello("A"), 1000) |
アロー関数で包む |
| メソッドを渡したい | run(() => counter.increment()) |
thisを保つためにアロー関数で包む |
| コールバックを受け取る関数を作る | function greet(name, callback) { callback(name); } |
引数で受け取り、中で callback() と呼ぶ |
コールバックを受け取る標準メソッドの早見表
| メソッド・関数 | コールバックが受け取る引数 | コールバックが返す値の意味 | 呼ばれるタイミング |
|---|---|---|---|
array.forEach(fn) |
要素, 番号, 配列 | 使われない | 同期(その場で要素の数だけ) |
array.map(fn) |
要素, 番号, 配列 | 新しい配列の要素になる | 同期 |
array.filter(fn) |
要素, 番号, 配列 | trueなら残す | 同期 |
array.find(fn) / findIndex(fn) |
要素, 番号, 配列 | trueになった最初の要素/番号を返す | 同期 |
array.some(fn) / every(fn) |
要素, 番号, 配列 | 1つでも/すべてtrueか | 同期 |
array.reduce(fn, 初期値) |
累積値, 要素, 番号, 配列 | 次の累積値になる | 同期 |
array.sort(fn) / toSorted(fn) |
a, b | 負ならaが先、正ならbが先 | 同期 |
string.replace(正規表現, fn) |
一致した文字列 ほか | 置き換える文字列 | 同期 |
setTimeout(fn, ミリ秒) |
なし(第3引数以降で渡せる) | 使われない | 非同期(指定時間のあとに1回) |
setInterval(fn, ミリ秒) |
なし | 使われない | 非同期(指定時間ごとに繰り返し) |
element.addEventListener(種類, fn) |
イベントオブジェクト | 使われない | 非同期(イベントが起きるたび) |
promise.then(fn) / catch(fn) |
結果の値/エラー | 次の then に渡る | 非同期(Promiseが完了したあと) |
fs.readFile(path, 文字コード, fn)(Node.js) |
エラー, 結果 | 使われない | 非同期(読み込みが終わったあと) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScriptコールバック関数とは?書き方からPromise置き換えまで」
コールバック関数とは
「関数を値として渡す」が出発点
JavaScriptでは、関数も数値や文字列と同じ「値」です。変数に入れることも、ほかの関数の引数に渡すこともできます。MDNの用語集では、コールバック関数を「引数として他の関数に渡され、外側の関数の中で呼び出されて、何らかのルーチンやアクションを完了させる関数」と説明しています(今回ページを開いて確認しました)。
登場するのは2つの関数です。
| 役割 | 呼び方 | やること |
|---|---|---|
| 関数を受け取る側 | 高階関数 | 全体の流れを決め、適切なタイミングでコールバックを呼ぶ |
| 渡される側 | コールバック関数 | そのタイミングで行う具体的な処理を書く |
いちばん小さな例と実行順
function greet(name, callback) {
console.log("1. greet 開始");
const message = `こんにちは、${name}さん`;
callback(message); // 受け取った関数をここで呼ぶ
console.log("3. greet 終了");
}
function show(text) {
console.log("2. コールバック:", text);
}
greet("田中", show); // show に () を付けずに渡す
実行結果:
1. greet 開始
2. コールバック: こんにちは、田中さん
3. greet 終了
show を呼び出しているのは自分のコードではなく、greet の中の callback(message) です。 greet は「あいさつ文を作る」ことだけを知っていて、その文をどう使うか(表示する、保存する、送信する)は渡す関数しだいで変えられます。これがコールバック関数を使う利点です。
sequenceDiagram
participant A as 呼び出し側
participant G as greet(高階関数)
participant S as show(コールバック)
A->>G: greet("田中", show)
G->>G: あいさつ文を作る
G->>S: callback(message)
S-->>G: 表示して戻る
G-->>A: 終了
コールバック関数の書き方3種類
同じ greet に、3通りの書き方で関数を渡します。
// 1. 定義済みの関数を名前で渡す
greet("田中", show);
// 2. 無名関数をその場に書く
greet("佐藤", function (text) {
console.log("2. 無名関数:", text);
});
// 3. アロー関数をその場に書く
greet("鈴木", (text) => console.log("2. アロー関数:", text));
実行結果(2と3の部分):
1. greet 開始
2. 無名関数: こんにちは、佐藤さん
3. greet 終了
1. greet 開始
2. アロー関数: こんにちは、鈴木さん
3. greet 終了
| 書き方 | 向いている場面 | 留意点 |
|---|---|---|
| 関数名で渡す | 同じ処理を何か所かで使う、あとで removeEventListener で外したい |
() を付けない |
| 無名関数(function) | 呼び出し元が設定する this を使いたい | thisは呼ばれ方で決まる |
| アロー関数 | 短い処理をその場に書く。現在の主流 | 自分のthisを持たず、外側のthisをそのまま使う |
処理を差し替えられる例
コールバックを受け取る関数を自分で作ると、「共通の流れ」と「場面ごとの処理」を分けられます。
function calc(a, b, op) {
return op(a, b);
}
console.log(calc(6, 3, (x, y) => x + y)); // 9
console.log(calc(6, 3, (x, y) => x * y)); // 18
console.log(calc(6, 3, Math.max)); // 6
function repeat(n, fn) {
for (let i = 0; i < n; i++) fn(i);
}
repeat(3, (i) => console.log(`${i}回目`)); // 0回目 1回目 2回目
Math.max のような組み込みの関数も、そのままコールバックとして渡せます。
同期コールバックと非同期コールバック
コールバックには、その場ですぐ呼ばれるもの(同期)と、あとで呼ばれるもの(非同期)があります。実行順を確かめます。
console.log("A");
[1, 2].forEach((n) => console.log("forEach", n));
setTimeout(() => console.log("setTimeout 0"), 0);
Promise.resolve().then(() => console.log("then"));
console.log("B");
実行結果:
A
forEach 1
forEach 2
B
then
setTimeout 0
| 種類 | 例 | 呼ばれるタイミング |
|---|---|---|
| 同期コールバック | forEach、map、filter、sort、reduce | メソッドを呼んだその場で実行され、終わってから次の行に進む |
| 非同期コールバック | setTimeout、addEventListener、then、fs.readFile | いま実行中のコードが最後まで終わったあとで実行される |
setTimeout(fn, 0) と書いても、すぐには実行されません。 いま動いているコード("B" の出力まで)が終わってから呼ばれます。また、Promiseの then に渡したコールバックは、setTimeoutのコールバックより先に実行されました。
この違いを知らないと、次のような場面で戸惑います。
let value = 1;
setTimeout(() => { value = 2; }, 0);
console.log(value); // 1(コールバックはまだ実行されていない)
実行すると 1 が出力され、少し待ってからもう一度出力すると 2 になっていました。非同期コールバックの結果は、コールバックの中か、その続きとして書いた処理で受け取ります。
同じ処理を4段階で書き換える
ここからが本題です。「会員を取得 → その会員の注文を取得 → 合計金額を計算」という3つの処理を順番に行うコードを、4つの書き方で比べます。
準備:0.1秒かかる処理を用意する
サーバーへの問い合わせの代わりに、setTimeoutで0.1秒後に結果を返す関数を3つ用意します。結果の返し方は、Node.jsで広く使われているエラーファースト形式(コールバックの第1引数がエラー、第2引数が結果)です。
// data.cjs : 0.1秒かかる処理の代わり(コールバック版)
const users = { 1: { id: 1, name: "田中" } };
const orders = { 1: [{ item: "ノート", price: 300 }, { item: "ペン", price: 120 }] };
function getUser(id, callback) {
setTimeout(() => {
if (!users[id]) return callback(new Error(`会員 ${id} が見つかりません`));
callback(null, users[id]);
}, 100);
}
function getOrders(user, callback) {
setTimeout(() => callback(null, orders[user.id]), 100);
}
function getTotal(list, callback) {
setTimeout(() => callback(null, list.reduce((sum, o) => sum + o.price, 0)), 100);
}
module.exports = { getUser, getOrders, getTotal };
以降のコードは、このファイルと同じフォルダに保存して node step1.cjs のように実行します。
第1段階:コールバック1つ
まずは会員の取得だけです。
// step1.cjs
const { getUser } = require("./data.cjs");
const start = Date.now();
const log = (msg) => console.log(`${String(Date.now() - start).padStart(3)}ms ${msg}`);
log("1. 開始");
getUser(1, (err, user) => {
if (err) return log("エラー: " + err.message);
log("3. 会員: " + user.name);
});
log("2. getUser を呼んだ直後");
実行結果:
0ms 1. 開始
7ms 2. getUser を呼んだ直後
108ms 3. 会員: 田中
コードの並びは「1 → 3 → 2」ですが、実行順は「1 → 2 → 3」です。 getUser は結果を待たずにすぐ戻り、約0.1秒後にコールバックが呼ばれます。コールバック1つなら、読みやすさに問題はありません。
第2段階:コールバックを重ねる(コールバック地獄)
注文の取得は会員の取得が終わってから、合計の計算は注文の取得が終わってから行います。コールバックだけで書くと、前の処理のコールバックの中に次の処理を書くことになります。
// step2.cjs
const { getUser, getOrders, getTotal } = require("./data.cjs");
const start = Date.now();
const log = (msg) => console.log(`${String(Date.now() - start).padStart(3)}ms ${msg}`);
log("1. 開始");
getUser(1, (err, user) => {
if (err) {
log("エラー: " + err.message);
} else {
log("3. 会員: " + user.name);
getOrders(user, (err, list) => {
if (err) {
log("エラー: " + err.message);
} else {
log("4. 注文: " + list.length + "件");
getTotal(list, (err, total) => {
if (err) {
log("エラー: " + err.message);
} else {
log("5. 合計: " + total + "円");
}
});
}
});
}
});
log("2. getUser を呼んだ直後");
実行結果:
0ms 1. 開始
7ms 2. getUser を呼んだ直後
107ms 3. 会員: 田中
208ms 4. 注文: 2件
308ms 5. 合計: 420円
動作は正しく、約0.1秒ずつ順番に進んでいます。一方で、コードには次の特徴が現れています。
| 特徴 | このコードでの様子 |
|---|---|
| 字下げが右へ深くなっていく | 3つの処理で6段。処理が増えるたびに2段ずつ深くなる |
| エラー処理が毎回必要 | if (err) が3か所に同じ形で並ぶ |
| 処理の流れが追いにくい | 閉じかっこ }); が末尾に連なり、どこまでがどの処理かを目で追う必要がある |
| 変数名が重なる | 内側の err が外側の err を隠している |
この、コールバックの入れ子が深くなって読みにくくなった状態が「コールバック地獄」と呼ばれています。 英語では、形が三角形になることから pyramid of doom とも呼ばれます。
第3段階:Promiseに置き換える
Promiseは「あとで結果が決まる処理」を表すオブジェクトです。成功したら resolve(結果)、うまくいかなかったら reject(エラー) を呼びます。まず、コールバック版の関数を Promiseを返す関数に包み直します。
// step3.cjs
const data = require("./data.cjs");
const start = Date.now();
const log = (msg) => console.log(`${String(Date.now() - start).padStart(3)}ms ${msg}`);
// コールバック版の関数を、Promise を返す関数に包む
function getUser(id) {
return new Promise((resolve, reject) => {
data.getUser(id, (err, user) => (err ? reject(err) : resolve(user)));
});
}
function getOrders(user) {
return new Promise((resolve, reject) => {
data.getOrders(user, (err, list) => (err ? reject(err) : resolve(list)));
});
}
function getTotal(list) {
return new Promise((resolve, reject) => {
data.getTotal(list, (err, total) => (err ? reject(err) : resolve(total)));
});
}
log("1. 開始");
getUser(1)
.then((user) => {
log("3. 会員: " + user.name);
return getOrders(user); // 次の Promise を return する
})
.then((list) => {
log("4. 注文: " + list.length + "件");
return getTotal(list);
})
.then((total) => {
log("5. 合計: " + total + "円");
})
.catch((err) => {
log("エラー: " + err.message); // どの段階のエラーもここに集まる
});
log("2. getUser を呼んだ直後");
実行結果:
0ms 1. 開始
8ms 2. getUser を呼んだ直後
109ms 3. 会員: 田中
209ms 4. 注文: 2件
310ms 5. 合計: 420円
実行結果と実行順は第2段階と同じまま、入れ子がなくなり、縦に並ぶ形になりました。 変わった点は2つです。
- 次の処理は、コールバックの中ではなく
.then()を続けて書く。thenの中で次のPromiseを return すると、その完了を待ってから次のthenに進む - エラー処理は最後の
.catch()1か所にまとまる
thenやcatchに渡しているのもコールバック関数です。Promiseはコールバックをなくす仕組みではなく、コールバックを入れ子にせず、つなげて書けるようにする仕組みだと言えます。
第4段階:async/awaitで書く
await は、Promiseの完了を待って結果を取り出す書き方です。async を付けた関数の中で使えます。
// step4.cjs
const { promisify } = require("node:util");
const data = require("./data.cjs");
const start = Date.now();
const log = (msg) => console.log(`${String(Date.now() - start).padStart(3)}ms ${msg}`);
// エラーファースト形式の関数は util.promisify で Promise 版にできる
const getUser = promisify(data.getUser);
const getOrders = promisify(data.getOrders);
const getTotal = promisify(data.getTotal);
async function main() {
try {
const user = await getUser(1);
log("3. 会員: " + user.name);
const list = await getOrders(user);
log("4. 注文: " + list.length + "件");
const total = await getTotal(list);
log("5. 合計: " + total + "円");
} catch (err) {
log("エラー: " + err.message);
}
}
log("1. 開始");
main();
log("2. main を呼んだ直後");
実行結果:
0ms 1. 開始
7ms 2. main を呼んだ直後
107ms 3. 会員: 田中
208ms 4. 注文: 2件
309ms 5. 合計: 420円
上から下へ、ふつうの順番で読めるコードになりました。 エラー処理も、同期処理と同じ try / catch で書けます。「2. main を呼んだ直後」が先に出力されている点は変わりません。awaitが待つのは main の中だけで、main を呼んだ側はそのまま先へ進みます。
第3段階ではPromiseへの包み直しを手で書きましたが、ここではNode.js標準の util.promisify を使いました。エラーファースト形式の関数であれば、1行でPromise版にできます。
4段階の比較表
| 比べる点 | 第1段階 コールバック1つ | 第2段階 コールバックの入れ子 | 第3段階 Promise | 第4段階 async/await |
|---|---|---|---|---|
| 実行結果(最終行) | 108ms 会員: 田中 | 308ms 合計: 420円 | 310ms 合計: 420円 | 309ms 合計: 420円 |
| 実行順 | 1 → 2 → 3 | 1 → 2 → 3 → 4 → 5 | 同じ | 同じ |
| 字下げの深さ | 1段 | 6段(処理ごとに2段増える) | 1段のまま | 1段のまま |
| エラー処理の書き方 | if (err) |
if (err) を3か所 |
.catch() 1か所 |
try / catch 1か所 |
| 存在しない会員(id=99)の実行結果 | 112ms エラー: 会員 99 が見つかりません | 107ms 同じメッセージ | 108ms 同じメッセージ | 107ms 同じメッセージ |
| 向いている場面 | イベント処理、配列メソッド、1回きりの非同期処理 | ― | 複数の処理を並行して待つ(Promise.all など) | 順番に行う非同期処理全般 |
id を 99 に変えて4つとも実行したところ、どの書き方でも約0.1秒後に同じエラーメッセージが出力され、後続の処理は実行されませんでした。 書き方が変わっても、動作は同じに保てることが確認できます。
どれを選ぶかの目安
| 場面 | 選び方 |
|---|---|
| 配列の加工(map、filter、sort) | コールバックをそのまま使う |
| ボタンのクリックなど、何度も起きるイベント | コールバック(addEventListener) |
| 非同期処理を2つ以上、順番に行う | async/await |
| 非同期処理を同時に進めて、全部そろうのを待つ | await Promise.all([...]) |
| 手元の関数がコールバック形式しかない | Promiseで包む、または util.promisify を使ってから await |
コールバックは役目を終えた書き方というわけではありません。「1回の結果を待つ非同期処理」はPromiseとasync/await、「同期的な加工」と「繰り返し起きるイベント」はコールバック、と場面で使い分けられています。
つまずきやすい誤りと直し方(エラー再現つき)
誤り1:関数に () を付けて渡してしまう
function done() {
console.log("done が呼ばれた");
}
console.log("before");
setTimeout(done(), 100); // () を付けている
console.log("after");
実行結果(Node.js v22.22.2):
before
done が呼ばれた
TypeError [ERR_INVALID_ARG_TYPE]: The "callback" argument must be of type function. Received undefined
done() と書くと、その場で done が実行され、その戻り値(ここでは undefined)が setTimeout に渡されます。 「before」の直後に「done が呼ばれた」と出ているのが、その証拠です。Node.jsでは上のTypeErrorになりました。ブラウザではエラーが出ず、待たずにすぐ実行されるだけに見えることが多い、とされています(ブラウザでは未実行です)。
| 書き方 | 意味 | 結果 |
|---|---|---|
setTimeout(done, 100) |
関数そのものを渡す | 0.1秒後に done が呼ばれる |
setTimeout(done(), 100) |
done を今すぐ実行し、戻り値を渡す | すぐ実行される。Node.jsではTypeError |
setTimeout(() => done(), 100) |
「done を呼ぶ関数」を渡す | 0.1秒後に done が呼ばれる |
同じ誤りは、ほかのメソッドでは次のようなメッセージになります。
| 実行したコード | 実行結果 |
|---|---|
[1, 2, 3].map(done()) |
TypeError: undefined is not a function |
[1, 2, 3].forEach("x") |
TypeError: string "x" is not a function |
greet("x")(コールバックを渡し忘れ) |
TypeError: callback is not a function |
promise.then(next()) |
エラーにならず、next が待たずにすぐ実行される |
eventTarget.addEventListener("x", done()) |
エラーにならず、Node.jsでは addEventListener called with undefined which has no effect. という警告が出る |
「〜 is not a function」というエラーを見たら、渡しているものが関数そのものかどうかを確認します。 thenやaddEventListenerのようにエラーにならないものは、「待たずにすぐ実行される」「イベントが起きても何も起きない」という形で現れます。
誤り2:コールバックに引数を渡したくて () を付ける
引数を渡したいときに setTimeout(hello("A"), 1000) と書きたくなりますが、これも誤り1と同じ形です。次の3つの書き方があります。
function hello(name) {
console.log("hello", name);
}
setTimeout(() => hello("A"), 10); // アロー関数で包む(おすすめ)
setTimeout(hello, 10, "B"); // setTimeout の第3引数以降で渡す
setTimeout(hello.bind(null, "C"), 10); // bind で引数を固定した関数を作る
実行結果:
hello A
hello B
hello C
誤り3:メソッドを渡すと this が失われる
const counter = {
count: 0,
increment() {
this.count++;
console.log("count =", this.count);
},
};
function run(cb) {
cb();
}
counter.increment(); // count = 1
run(counter.increment); // メソッドをコールバックとして渡す
実行結果(ESモジュール、つまりstrictモードで実行):
count = 1
TypeError: Cannot read properties of undefined (reading 'count')
thisは「どこで定義したか」ではなく「どう呼ばれたか」で決まります。 counter.increment() と呼べば this は counter ですが、run(counter.increment) では関数だけが取り出されて渡され、run の中で cb() と単独で呼ばれます。そのため this は undefined になります。strictモードではないCommonJSのファイルで同じコードを実行すると、エラーにはならず count = NaN と表示され、counter.count は 0 のままでした。エラーが出ないぶん、気づきにくくなります。
直し方は2つあります。
run(() => counter.increment()); // アロー関数で包む → count = 2
run(counter.increment.bind(counter)); // bind で this を固定する → count = 3
クラスの中で setTimeout を使うときも同じことが起きます。
class Timer {
constructor() { this.sec = 0; }
startBefore() {
setTimeout(function () {
this.sec++; // この this は Timer のインスタンスではない
console.log("function:", this.sec);
}, 10);
}
startAfter() {
setTimeout(() => {
this.sec++; // アロー関数は外側の this をそのまま使う
console.log("arrow:", this.sec);
}, 10);
}
}
実行結果:
function: NaN
arrow: 1
| コールバックの書き方 | thisが指すもの |
|---|---|
function () { ... } |
呼び出し側が決める。単独で呼ばれると undefined(strictモード)。Node.jsのsetTimeoutではタイマーのオブジェクトになり、上の例では NaN になった |
() => { ... } |
定義した場所の外側の this をそのまま使う |
obj.method.bind(obj) |
bind で指定したオブジェクトに固定される |
クラスのフィールドにアロー関数で定義(handle = () => { ... }) |
そのインスタンスに固定される(そのまま渡して動くことを確認済み) |
イベントリスナーでは、function で書いたコールバックの this は「リスナーを登録した対象」になります。Node.jsのEventTargetで確かめたところ、functionでは this === 登録した対象 が true、アロー関数では this は undefined でした。クラスのメソッドの中からコールバックを渡すときはアロー関数、と決めておくと迷いません。
誤り4:エラーファースト形式で err を確認しない
Node.jsのコールバック形式のAPIは、第1引数にエラー、第2引数に結果を渡す決まりになっています。成功したとき、第1引数は null です。
const fs = require("node:fs");
fs.readFile("memo.txt", "utf8", (err, text) => {
if (err) {
console.log("読み込めませんでした:", err.code, err.message);
return;
}
console.log("内容:", text);
});
実行結果(memo.txt がない場合):
読み込めませんでした: ENOENT ENOENT: no such file or directory, open 'memo.txt'
if (err) の確認を省いて console.log(text.length) と書くと、次のエラーでプログラムが終了しました。
TypeError: Cannot read properties of undefined (reading 'length')
エラーファースト形式でよくある誤りを表にまとめます。
| 誤り | 実行結果 | 直し方 |
|---|---|---|
| err を確認せずに結果を使う | TypeError: Cannot read properties of undefined (reading 'length') |
最初に if (err) return ...; を書く |
引数を (user) => ... と1つだけ書く |
TypeError: Cannot read properties of null (reading 'name')(第1引数の null を受け取っている) |
(err, user) => ... と2つ書く |
| エラー時に callback を呼んだあと return しない | コールバックが2回呼ばれた(「flag が必要です」のあとに「OK」も出力) | return callback(new Error(...)); と return を付ける |
現在のNode.jsでは、多くのAPIにPromise版が用意されています。たとえば node:fs/promises の readFile は、await と try / catch で書けます(存在しないファイルを指定して err.code が ENOENT になることを確認しました)。
import { readFile } from "node:fs/promises";
try {
const text = await readFile("memo.txt", "utf8");
console.log(text);
} catch (err) {
console.log("読み込めませんでした:", err.code);
}
誤り5:非同期コールバックのエラーを外側の try / catch で受けようとする
try {
setTimeout(() => {
throw new Error("コールバックの中で発生");
}, 10);
} catch (e) {
console.log("捕捉:", e.message);
}
console.log("try を抜けました");
実行結果:
try を抜けました
Error: コールバックの中で発生
「捕捉:」は出力されず、エラーでプログラムが終了しました。 コールバックが実行されるのは、try のブロックを抜けたあとだからです。対処は、コールバックの中に try / catch を書く(「内側で捕捉」できることを確認済み)か、Promiseで包んで await し、第4段階のように try / catch で受ける形にします。
誤り6:非同期コールバックの結果を return で返そうとする
function getValue() {
let result;
setTimeout(() => { result = "データ"; }, 10);
return result;
}
console.log(getValue()); // undefined
return が実行される時点では、コールバックはまだ呼ばれていません。結果はコールバックの引数で受け取るか、Promiseを返して await で受け取ります。
誤り7:コールバックが受け取る引数を見落とす
console.log(["10", "10", "10"].map(parseInt)); // [ 10, NaN, 2 ]
console.log(["10", "10", "10"].map(Number)); // [ 10, 10, 10 ]
console.log(["10", "10", "10"].map((s) => parseInt(s, 10))); // [ 10, 10, 10 ]
mapはコールバックに「要素, 番号, 配列」の3つを渡します。parseIntは第2引数を基数(何進数か)として受け取るため、番号の 0、1、2 が基数として使われ、[ 10, NaN, 2 ] になりました。既存の関数をそのまま渡すときは、相手が何を引数として渡してくるかを早見表で確認すると安心です。
そのほか、短いコードで起きやすいものをまとめます。
| 実行したコード | 実行結果 | 理由と直し方 |
|---|---|---|
[1, 2, 3].map((n) => { n * 2 }) |
[ undefined, undefined, undefined ] |
波かっこを付けたら return が必要。(n) => n * 2 と書く |
[10, 9, 1, 100].sort() |
[ 1, 10, 100, 9 ] |
比較関数を省くと文字列として並べる。sort((a, b) => a - b) で [ 1, 9, 10, 100 ] |
[1, 2, 3].forEach(async (n) => { await ...; }) |
「forEach の直後」が先に出力された | forEach は完了を待たない。順番に待つなら for...of の中で await する |
addEventListener("ping", () => ...) のあと、同じ内容のアロー関数で removeEventListener |
リスナーが外れず、イベントで呼ばれた | 見た目が同じでも別の関数。変数や関数名で同じ関数を渡す |
コールバックを受け取る標準メソッドの使用例
冒頭の早見表にあるメソッドを、1つのデータで動かします。
const items = [
{ name: "ノート", price: 300, stock: 5 },
{ name: "ペン", price: 120, stock: 0 },
{ name: "付箋", price: 250, stock: 12 },
];
console.log(items.map((i) => i.name)); // 名前だけの配列
console.log(items.filter((i) => i.stock > 0).map((i) => i.name)); // 在庫ありの名前
console.log(items.find((i) => i.price < 200).name); // 条件に合う最初の1件
console.log(items.findIndex((i) => i.price < 200)); // その番号
console.log(items.some((i) => i.stock === 0)); // 在庫0が1つでもあるか
console.log(items.every((i) => i.price >= 100)); // すべて100円以上か
console.log(items.reduce((sum, i) => sum + i.price * i.stock, 0)); // 在庫金額の合計
console.log(items.toSorted((a, b) => a.price - b.price).map((i) => i.name)); // 価格の安い順
items.forEach((item, index, arr) => console.log(index, item.name, arr.length));
実行結果:
[ 'ノート', 'ペン', '付箋' ]
[ 'ノート', '付箋' ]
ペン
1
true
true
4500
[ 'ペン', '付箋', 'ノート' ]
0 ノート 3
1 ペン 3
2 付箋 3
配列以外にも、コールバックを受け取るものがあります。
console.log("abc-def".replace(/[a-z]+/g, (m) => m.toUpperCase())); // ABC-DEF
console.log(Array.from({ length: 3 }, (_, i) => i * i)); // [ 0, 1, 4 ]
console.log(JSON.stringify({ a: 1, b: "x" }, (k, v) => (typeof v === "number" ? v * 2 : v)));
// {"a":2,"b":"x"}
addEventListener と removeEventListener
イベントリスナーは、「イベントが起きたら呼んでほしい関数」を登録する、非同期コールバックの代表例です。Node.jsにもある EventTarget で動作を確かめました。
const target = new EventTarget();
function onPing(event) {
console.log("受信:", event.type);
}
target.addEventListener("ping", onPing); // 関数名で登録(() は付けない)
target.dispatchEvent(new Event("ping")); // 受信: ping
target.removeEventListener("ping", onPing); // 同じ関数を渡して解除
target.dispatchEvent(new Event("ping")); // 何も出力されない
target.addEventListener("ping", () => console.log("once"), { once: true });
target.dispatchEvent(new Event("ping")); // once
target.dispatchEvent(new Event("ping")); // 何も出力されない(1回で自動解除)
ブラウザでボタンに使う場合は、次の形になります(ブラウザでは未実行です。登録と解除の考え方は上と同じです)。
const button = document.querySelector("#save");
button.addEventListener("click", (event) => {
console.log("クリックされました", event.target);
});
setTimeout と setInterval
const id = setInterval(() => console.log("tick"), 10); // 10ミリ秒ごとに呼ばれる
setTimeout(() => clearInterval(id), 35); // 35ミリ秒後に止める
実行すると「tick」が3回出力されて止まりました。setInterval は止めるまで呼ばれ続けるので、戻り値のIDを取っておき、clearInterval で止めます。
よくある質問
Q. コールバック関数とふつうの関数は何が違いますか?
A. 関数そのものに違いはありません。ほかの関数に引数として渡され、渡した先で呼び出される、という「使われ方」をコールバックと呼びます。同じ show 関数でも、show("x") と自分で呼べばふつうの呼び出し、greet("田中", show) と渡せばコールバックです。
Q. コールバック関数に () を付けてはいけないのはなぜですか?
A. () を付けるとその場で実行され、戻り値が渡されるからです。setTimeout(done(), 100) を実行すると、done がすぐ呼ばれたうえで、Node.jsでは The "callback" argument must be of type function. Received undefined というTypeErrorになりました。関数名だけを書くか、() => done() と包みます。
Q. コールバック地獄を解消するにはどうすればよいですか?
A. 非同期処理をPromiseを返す関数にして、async/awaitで書きます。記事中の例では、6段あった字下げが1段になり、3か所の if (err) が try / catch 1か所にまとまりました。コールバック形式の関数は、new Promise() で包むか、Node.jsなら util.promisify でPromise版にできます。
Q. Promiseやasync/awaitがあれば、コールバックは覚えなくてよいですか? A. コールバックは今も広く使われています。map・filter・sortなどの配列メソッド、addEventListener、そしてPromiseの then に渡すのもコールバック関数です。順番に行う非同期処理はasync/await、加工とイベントはコールバック、と使い分けます。
まとめ
- コールバック関数は、ほかの関数に渡して、渡した先で呼び出してもらう関数。 書き方は関数名・無名関数・アロー関数の3種類
- 渡すときは
()を付けない。 付けるとその場で実行され、is not a functionなどのエラーにつながる - 同期コールバック(map、forEachなど)はその場で、非同期コールバック(setTimeout、イベント、then)はいまのコードが終わってから実行される
- 非同期処理をコールバックだけで順番につなぐと入れ子が深くなる。 これがコールバック地獄
- Promiseで包むと then でつなげられ、async/awaitで書くと上から下へ読める。 4段階とも実行結果と実行順は同じだった
- メソッドを渡すと this が失われる。 アロー関数で包むか bind を使う
- エラーファースト形式では、最初に
if (err)を確認し、非同期コールバックの中のエラーは外側の try / catch では受け取れない
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScriptコールバック関数とは?書き方からPromise置き換えまで」
株式会社RJCでは、JavaScriptのコールバック関数や非同期処理の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。