JavaScriptの基礎 - コールバック関数

提供: MochiuWiki : SUSE, EC, PCB

概要

JavaScriptにおける関数はファーストクラスオブジェクト (First-class Object) として扱われるため、変数への代入、他の関数への引数として渡すこと、関数の戻り値として返すことが全て可能である。
この性質を活用した設計パターンがコールバック関数である。

コールバック関数とは、別の関数に引数として渡される関数のことであり、呼び出し先の関数が適切なタイミングでそのコールバックを実行する。
"何をするか" をコールバック関数として定義し、"いつ実行するか" を呼び出し側の高階関数に委ねることにより、処理の柔軟な分離が実現できる。

コールバック関数は、実行タイミングによって同期コールバックと非同期コールバックの2種類に分類される。
mapfilterforEach 等の配列メソッドは同期コールバックの代表例であり、呼び出し元のコードをブロックしながら即座に実行される。
一方、setTimeoutsetIntervaladdEventListener 等は非同期コールバックの代表例であり、特定のイベントや時間の経過を待って実行される。

関数を引数として受け取る または 関数を戻り値として返す関数のことを高階関数 (Higher-order Function) と呼ぶ。
JavaScript組み込みの配列メソッドの多くは高階関数であり、コールバック関数と組み合わせることで簡潔かつ表現力の高いコードを記述できる。

非同期処理を順番に実行するためにコールバックを多重にネストすると コールバック地獄 と呼ばれる問題が発生する。
この問題は、ES2015のPromiseおよびES2017のasync / awaitで解決されている。

Reactでは、onClickonChangeonSubmit 等のPropsにコールバック関数を渡すことにより、イベントを処理する。

ブラウザネイティブのイベントシステムと同様の設計パターンが採用されているため、DOMイベントの理解はReactの学習にも直結する。


コールバック関数とは

基本的な概念

コールバック関数とは、別の関数に引数として渡される関数であり、渡された先の関数が適切なタイミングで呼び出す。

基本的なコールバックのパターンを以下に示す。

 // コールバック関数を受け取る関数
 function doSomething(callback) {
    console.log("処理を開始する");
    callback();  // 渡されたコールバックを実行する
    console.log("処理が完了した");
 }
 
 // コールバック関数として渡す関数
 function myCallback() {
    console.log("コールバックが実行された");
 }
 
 doSomething(myCallback);
 // "処理を開始する"
 // "コールバックが実行された"
 // "処理が完了した"


コールバックには名前付き関数だけでなく、無名関数式やアロー関数を直接渡すことも多い。

 // 無名関数をコールバックとして渡す
 doSomething(function() {
    console.log("無名関数のコールバック");
 });
 
 // アロー関数をコールバックとして渡す
 doSomething(() => {
    console.log("アロー関数のコールバック");
 });


コールバックパターンの本質は、"何をするか""いつ実行するか" を分離することにある。
呼び出し元はコールバックに処理の内容を定義し、呼び出し先の高階関数はそのコールバックをいつ・どのように実行するかを制御する。

この分離により、汎用的な高階関数と多様な処理ロジックを組み合わせることができる。

同期コールバック と 非同期コールバック

コールバック関数は実行タイミングによって同期と非同期の2種類に分類される。

同期コールバックは、関数が呼び出された直後にそのまま実行される。
配列メソッドの forEachmapfilter 等が代表的な同期コールバックである。

 // 同期コールバック (forEachは即座にコールバックを実行する)
 console.log("1: 開始");
 
 [1, 2, 3].forEach(function(n) {
    console.log("コールバック: " + n);
 });
 
 console.log("2: 終了");
 
 // 出力順:
 // "1: 開始"
 // "コールバック: 1"
 // "コールバック: 2"
 // "コールバック: 3"
 // "2: 終了"


非同期コールバックは、現在の同期処理が完了した後にイベントループを経由して実行される。
setTimeoutsetIntervaladdEventListener 等が代表的な非同期コールバックである。

 // 非同期コールバック (setTimeoutは後でコールバックを実行する)
 console.log("1: 開始");
 
 setTimeout(function() {
    console.log("コールバック: タイマ発火");
 }, 1000);
 
 console.log("2: 終了");
 
 // 出力順:
 // "1: 開始"
 // "2: 終了"
 // (1秒後) "コールバック: タイマ発火"


下表に、同期コールバックと非同期コールバックの比較を示す。

同期コールバックと非同期コールバックの比較
種類 実行タイミング 代表例 特徴
同期 即座に実行 map, filter, reduce, forEach, sort 呼び出し元の処理がブロックされる
非同期 後で実行 setTimeout, setInterval, addEventListener, fetch 呼び出し元の処理をブロックしない



高階関数

高階関数とは

高階関数 (Higher-order Function) とは、関数を引数として受け取る または 関数を戻り値として返す関数のことである。

JavaScriptには多くの組み込み高階関数が用意されており、配列操作に特に多く使用される。

主な組み込み高階関数と使用例を以下に示す。

 const numbers = [1, 2, 3, 4, 5];
 
 // map: 各要素にコールバックを適用した新しい配列を返す
 const doubled = numbers.map(function(n) { return n * 2; });
 console.log(doubled);  // [2, 4, 6, 8, 10]
 
 // filter: コールバックがtrueを返す要素だけの新しい配列を返す
 const evens = numbers.filter(function(n) { return n % 2 === 0; });
 console.log(evens);    // [2, 4]
 
 // reduce: 配列を単一の値に集約する
 const sum = numbers.reduce(function(total, n) { return total + n; }, 0);
 console.log(sum);      // 15
 
 // forEach: 各要素に対して処理を実行する (戻り値はundefined)
 numbers.forEach(function(n) { console.log(n); });
 
 // find: 条件を満たす最初の要素を返す
 const found = numbers.find(function(n) { return n > 3; });
 console.log(found);    // 4
 
 // some: 条件を満たす要素が1つでもあればtrueを返す
 const hasLarge = numbers.some(function(n) { return n > 4; });
 console.log(hasLarge); // true
 
 // every: すべての要素が条件を満たせばtrueを返す
 const allPositive = numbers.every(function(n) { return n > 0; });
 console.log(allPositive); // true
 
 // sort: 比較関数で要素を並び替える
 const sorted = [3, 1, 4, 1, 5].sort(function(a, b) { return a - b; });
 console.log(sorted);   // [1, 1, 3, 4, 5]


自作の高階関数

高階関数を自作することにより、処理の抽象化や繰り返し処理の共通化が実現できる。

  • 繰り返し処理を抽象化した高階関数の例
     // n回actionを繰り返す高階関数
     function repeat(n, action) {
        for (let i = 0; i < n; i++) {
           action(i);
        }
     }
     
     repeat(3, function(i) {
        console.log("繰り返し: " + i);
     });
     
     // "繰り返し: 0"
     // "繰り返し: 1"
     // "繰り返し: 2"
    

  • 条件を外部から注入するパターンの例
    高階関数のインターフェースを固定しながら、具体的な処理内容をコールバックで差し替えることができる。
     // 処理の共通化: データの変換パターン
     function processArray(array, transform) {
        const result = [];
        for (const item of array) {
           result.push(transform(item));
        }
        return result;
     }
     
     const names = ["alice", "bob", "charlie"];
     
     const upperNames = processArray(names, function(name) {
        return name.toUpperCase();
     });
     console.log(upperNames);  // ["ALICE", "BOB", "CHARLIE"]
     
     const lengths = processArray(names, function(name) {
        return name.length;
     });
     console.log(lengths);  // [5, 3, 7]
    


関数を返す高階関数

高階関数は関数を引数として受け取るだけでなく、関数を戻り値として返すこともできる。
この仕組みはクロージャと組み合わせて、関数ファクトリとして活用される。

 // 関数を返す高階関数 (関数ファクトリ)
 function createMultiplier(multiplier) {
    return function(number) {
       return number * multiplier;
    };
 }
 
 const double = createMultiplier(2);
 const triple = createMultiplier(3);
 
 console.log(double(5));  // 10
 console.log(triple(5));  // 15


関数を返す高階関数の詳細な仕組みについては、JavaScriptの基礎 - クロージャのページを参照すること。


setTimeout / setInterval

setTimeout

setTimeout は、指定した時間 (ミリ秒) が経過した後にコールバック関数を1度だけ実行するタイマ関数である。

基本構文を以下に示す。

 const timerId = setTimeout(callback, delay);


setTimeout() の引数
引数 説明
callback <delay>ミリ秒後に実行する関数
delay 遅延時間 (ミリ秒単位)
省略時は、0として扱われる。


使用例を以下に示す。

 // 1秒後にメッセージを表示する
 const timerId = setTimeout(function() {
    console.log("1秒が経過した");
 }, 1000);
 
 // コールバックに引数を渡す場合は、第3引数以降に指定する
 setTimeout(function(name, greeting) {
    console.log(greeting + ", " + name);
 }, 2000, "Alice", "Hello");
 // 2秒後: "Hello, Alice"


clearTimeout を使用するとタイマをキャンセルできる。
setTimeout の戻り値であるタイマIDを clearTimeout に渡すことにより、キャンセルが実現できる。

 const timerId = setTimeout(function() {
    console.log("このメッセージは表示されない");
 }, 3000);
 
 // タイマをキャンセルする
 clearTimeout(timerId);


setInterval

setInterval は、指定した間隔 (ミリ秒) ごとにコールバック関数を繰り返し実行するタイマ関数である。

基本構文を以下に示す。

 const intervalId = setInterval(callback, interval);


使用例を以下に示す。

 // 1秒ごとに現在時刻を表示する
 const intervalId = setInterval(function() {
    const now = new Date().toLocaleTimeString();
    console.log("現在時刻: " + now);
 }, 1000);
 
 // 5秒後にタイマを停止する
 setTimeout(function() {
    clearInterval(intervalId);
    console.log("タイマを停止した");
 }, 5000);


clearInterval を使用するとタイマを停止できる。
setInterval の戻り値であるインターバルIDを clearInterval に渡すことで停止が実現できる。

タイマとthis

通常の関数をコールバックとして setTimeoutsetInterval に渡すと、
コールバック内の this がグローバルオブジェクト (window、strict modeでは undefined) に変わってしまう問題がある。

 const counter = {
    count: 0,
    start: function() {
       // 問題: setTimeoutのコールバック内のthisはwindowを指す
       setTimeout(function() {
          this.count++;  // エラー: windowにcountプロパティはない
          console.log(this.count);
       }, 1000);
    }
 };


この問題はアロー関数を使用することで解決できる。
アロー関数は自身の this を持たず、定義時の外側のスコープの this を引き継ぐ性質があるため、意図した this の参照を維持できる。

 const counter = {
    count: 0,
    start: function() {
       // 解決策: アロー関数を使用すると外側のthisを引き継ぐ
       setTimeout(() => {
          this.count++;  // counterオブジェクトのcountを参照できる
          console.log(this.count);  // 1
       }, 1000);
    }
 };
 
 counter.start();


レキシカルthis の詳細については、JavaScriptの基礎 - アロー関数のページを参照すること。

タイマの注意点

タイマ関数を使用する場合、以下に示す事柄に注意する必要がある。

  • 遅延時間は最小値の保証にすぎない。

    JavaScriptはシングルスレッドで動作するため、他の処理が実行中の場合はタイマの実行が遅延する場合がある。
    指定した遅延時間は 最低でもその時間後に実行される という意味であり、正確なタイミングを保証するものではない。

     // delay = 0 でもイベントループ経由で実行される
     console.log("1: 同期処理の開始");
     
     setTimeout(function() {
        console.log("3: setTimeout(delay=0)のコールバック");
     }, 0);
     
     console.log("2: 同期処理の終了");
     
     // 出力順:
     // "1: 同期処理の開始"
     // "2: 同期処理の終了"
     // "3: setTimeout(delay=0)のコールバック"
    

  • setInterval はコールバックの処理時間がインターバルを超えると問題が発生することがある。
    この場合はネストした setTimeout を使用する方法がより安全である。

     // setIntervalの問題: 処理時間がインターバルを超えるとキューが詰まる可能性がある
     setInterval(function() {
        // 重い処理 (処理時間が1000[ms]を超えることがある)
        heavyTask();
     }, 1000);
     
     // 解決策: ネストしたsetTimeoutを使用する
     // 前の処理が完了してからインターバルをカウントし始める
     function scheduleNext() {
        setTimeout(function() {
           heavyTask();
           scheduleNext();  // 処理完了後に次のタイマをセットする
        }, 1000);
     }
     
     scheduleNext();
    



イベントリスナー

addEventListener

addEventListener は、DOM要素に対してイベントリスナー (コールバック関数) を登録するメソッドである。

基本構文を以下に示す。

 element.addEventListener(eventType, callback);
 element.addEventListener(eventType, callback, options);


使用例を以下に示す。

 const button = document.getElementById("myButton");
 
 // クリックイベントのリスナーを登録する
 button.addEventListener("click", function(event) {
    console.log("ボタンがクリックされた");
    console.log(event.target);    // イベントが発生した要素
    console.log(event.type);      // "click"
 });
 
 // 入力イベントのリスナーを登録する
 const input = document.getElementById("myInput");
 input.addEventListener("input", function(event) {
    console.log("入力値: " + event.target.value);
 });


コールバックに渡されるイベントオブジェクト (event) には、発生したイベントに関する情報が格納される。

イベントオブジェクトの主要なプロパティとメソッド
プロパティ/メソッド 説明
event.target イベントが発生した要素
event.currentTarget イベントリスナーが登録されている要素
event.type イベントの種類 ("click"、"input"等)
event.preventDefault() リンクのページ遷移やフォームの送信等、ブラウザのデフォルト動作を防止する。
event.stopPropagation() イベントの親要素への伝播 (バブリング) を停止する。


removeEventListener

removeEventListener を使用すると、登録したイベントリスナーを解除できる。

解除するには、addEventListener に渡したものと同一の関数参照を指定する必要がある。

 // 名前付き関数を使用する場合は解除が可能
 function handleClick(event) {
    console.log("クリックされた");
 }
 
 const button = document.getElementById("myButton");
 button.addEventListener("click", handleClick);
 
 // 同一の関数参照を渡すことで解除できる
 button.removeEventListener("click", handleClick);


無名関数をコールバックとして渡す場合、同一の関数参照を取得できないためリスナーを解除することができない。

 // 無名関数を使用した場合は解除できない
 button.addEventListener("click", function() {
    console.log("クリックされた");
 });
 
 // 下記の解除は無効 (異なる関数オブジェクトを渡しているため)
 button.removeEventListener("click", function() {
    console.log("クリックされた");
 });


リスナーを解除する必要がある場合は、名前付き関数 または 変数に格納した関数式を使用することを推奨する。

よく使用するイベントの種類

下表に、DOMで使用頻度の高いイベントを示す。

よく使用するDOMイベント一覧
イベント名 発生タイミング 対象要素
click 要素がクリックされた時 ほぼ全ての要素
dblclick 要素がダブルクリックされた時 ほぼ全ての要素
input 入力値が変化した時 input, textarea, select
change 値が確定した時 (フォーカス離脱後) input, textarea, select
submit フォームが送信された時 form
keydown キーが押された時 フォーカス可能な要素
keyup キーが離された時 フォーカス可能な要素
focus 要素にフォーカスが当たった時 フォーカス可能な要素
blur 要素からフォーカスが外れた時 フォーカス可能な要素
scroll 要素がスクロールされた時 スクロール可能な要素, window
load リソースの読み込みが完了した時 window, img, script
DOMContentLoaded HTMLの解析が完了した時 document



コールバックの課題

コールバック地獄

複数の非同期処理を順番に実行する際に、コールバックをネストしていくと、コードの階層が深くなる問題が発生する。
この問題は、コールバック地獄 (Callback Hell) と呼ばれる。

コールバック地獄の例を以下に示す。

 // コールバック地獄: ネストが深くなりコードが読みにくい
 getUser(userId, function(err, user) {
    if (err) {
       handleError(err);
       return;
    }
    getOrders(user.id, function(err, orders) {
       if (err) {
          handleError(err);
          return;
       }
       getOrderDetails(orders[0].id, function(err, details) {
          if (err) {
             handleError(err);
             return;
          }
          // さらに深いネスト...
          updateInventory(details.productId, function(err, result) {
             if (err) {
                handleError(err);
                return;
             }
             console.log("処理が完了した");
          });
       });
    });
 });


コールバック地獄には以下の問題がある。

  • 可読性の低下
    インデントの深さとともにコードが複雑になり、処理の流れを追うことが困難になる
  • エラーハンドリングの重複
    各ネストでエラーチェックが必要となり、同様のコードが繰り返される
  • 保守性の低下
    処理の追加・変更・削除が難しくなる


この問題を解決するために、ES2015でPromiseが導入され、ES2017でasync / awaitが導入された。

エラーハンドリング

コールバック関数内でのエラーハンドリングには、いくつかのパターンがある。

Node.jsではエラーファーストコールバック (Error-first Callback) という規約が広く採用されている。
この規約では、コールバックの第1引数にエラーオブジェクト (成功時は、null)、第2引数以降にデータを渡す。

 // エラーファーストコールバックの規約
 // callback(err, data) - 第1引数がエラー、第2引数がデータ
 function readFile(path, callback) {
    // ファイル読み込み処理
    if (/* 成功 */ true) {
       callback(null, "ファイルの内容");  // エラーはnull
    }
    else {
       callback(new Error("ファイルが見つからない"));  // エラーを第1引数に渡す
    }
 }
 
 // 呼び出し側でのエラーハンドリング
 readFile("/path/to/file.txt", function(err, data) {
    if (err) {
       console.error("エラーが発生した:", err.message);
       return;
    }
    console.log("ファイルの内容:", data);
 });


Webブラウザのイベントリスナーでのエラーハンドリングには try / catch を使用する。

 button.addEventListener("click", function(event) {
    try {
       // エラーが発生する可能性のある処理
       const result = riskyOperation();
       console.log("成功:", result);
    }
    catch (error) {
       console.error("エラーが発生した:", error.message);
    }
 });



Reactのイベントハンドラ

Reactでのコールバックパターン

Reactでは、DOM要素に対応するJSX要素のPropsにコールバック関数を渡すことでイベントを処理する。
イベントPropsの名前はキャメルケースで記述する。(onClickonChangeonSubmit 等)

Reactに渡されるイベントオブジェクトは、ブラウザネイティブイベントのラッパーであるSyntheticEvent (合成イベント) である。
SyntheticEventはクロスブラウザ互換性を確保するために設計されており、ネイティブイベントと同様のインターフェースを持つ。

  • 基本的なイベントハンドラのパターン
    コールバック関数は渡す (参照を渡す) だけであり、呼び出してはならない。
    onClick={handleClick} が正しく、onClick={handleClick()} は誤りである。
    例えば、onClick={handleClick()} と記述すると、レンダリング時に関数が即座に実行されてしまい、意図しない動作を引き起こす。

     function MyComponent() {
        // 外部で定義した関数をコールバックとして渡す (推奨)
        function handleClick(event) {
           console.log("ボタンがクリックされた");
           console.log(event.target);
        }
     
        function handleChange(event) {
           console.log("入力値: " + event.target.value);
        }
     
        return (
           <div>
              {/* 関数を渡す (呼び出さない) */}
              <button onClick={handleClick}>クリック</button>
              <input onChange={handleChange} />
           </div>
        );
     }
    

  • インラインでアロー関数を直接記述することもできる。
     function MyComponent() {
        return (
           <button onClick={() => console.log("クリックされた")}>
              クリック
           </button>
        );
     }
    


ただし、インラインのアロー関数はレンダリングのたびに新しい関数オブジェクトが生成される。
複雑な処理や最適化が必要な場合は、外部で関数を定義してから渡すことを推奨する。

イベントハンドラに引数を渡す

Reactのイベントハンドラに追加の引数を渡す場合は、アロー関数でラップする方法が広く使用される。

 function TodoList() {
    const todos = [
       { id: 1, text: "買い物をする" },
       { id: 2, text: "掃除をする" },
       { id: 3, text: "メールを返信する" }
    ];
 
    function handleDelete(id) {
       console.log("削除するtodoのID: " + id);
    }
 
    function handleEdit(id, newText) {
       console.log("編集するID: " + id + ", 新しいテキスト: " + newText);
    }
 
    return (
       <ul>
          {todos.map(function(todo) {
             return (
                <li key={todo.id}>
                   {todo.text}
                   {/* アロー関数で引数を渡す */}
                   <button onClick={() => handleDelete(todo.id)}>
                      削除
                   </button>
                   <button onClick={() => handleEdit(todo.id, "新しいテキスト")}>
                      編集
                   </button>
                </li>
             );
          })}
       </ul>
    );
 }


onClick={() => handleDelete(todo.id)} と記述することにより、クリックされた時に handleDeletetodo.id を引数として呼び出される。
ここで、アロー関数はコールバックのラッパーとして機能しており、クリック時に実行されるのはアロー関数であり、その中で handleDelete が引数付きで呼び出される。

フォームの送信イベントでデフォルト動作を防止する例を以下に示す。

 function LoginForm() {
    function handleSubmit(event) {
       event.preventDefault();  // フォームのデフォルト送信動作を防止する
       console.log("フォームが送信された");
    }
 
    return (
       <form onSubmit={handleSubmit}>
          <input type="text" placeholder="ユーザ名" />
          <input type="password" placeholder="パスワード" />
          <button type="submit">ログイン</button>
       </form>
    );
 }



関連情報