JavaScriptのコールバック関数と非同期処理を丁寧に理解する

JavaScriptで重要な概念とされるコールバック関数ですが、使い方がよくわからない人も多いようです。

特に、非同期処理との絡みでは理解が難しく、結局スニペットを探していませんでしょうか。

本記事では、まずコールバック関数が何なのかをシンプルに説明し、次にコールバック関数がなぜ非同期処理とともに使われるのか、コード例を通して丁寧に解説していきます。

JavaScriptでは、関数 = 値

コールバック関数とは何かを理解する前に、JavaScriptにおける関数の重要な性質を一つだけ押さえましょう。

JavaScriptでは、関数と値は同じ扱いで、正確には第一級オブジェクトというものに所属しています。

厳密な定義はともかく、関数も変数に代入したり、値として渡したりできる という点だけ理解しておきましょう。

{"code":"\/\/ \u95a2\u6570\u3092\u5909\u6570f\u306b\u4ee3\u5165\nconst f = (a, b) => { return a + b }\nconsole.log( f(2, 3) );\n\/\/ 5\nconsole.log( f(3, 5) );\n\/\/ 8","language":"javascript","id":1,"filename":""}

関数を、普通の値のように別の関数に渡すこともできます。柔軟ですね。

{"code":"function greeting(callback){\n    console.log('Hello,');\n    callback('World!'); \/\/ \u3082\u3089\u3063\u305f\u95a2\u6570\u3092\u5b9f\u884c\n}\nfunction print_word(word){\n    console.log(word);\n}\n\/\/ greeting\u306b\u3001\u95a2\u6570print_word\u3092\u6e21\u3057\u3066\u5b9f\u884c\u3057\u3066\u3044\u308b\ngreeting(print_word);\n\n\/\/ Hello,\n\/\/ World!","language":"javascript","id":1,"filename":""}

上の例でいう関数greeting()は、関数を引数として受け取って、自身の中で実行する関数になっていますね。このような関数を高階関数といいます。

コールバック関数とは

コールバック関数とは、高階関数に渡す関数です。

前章の例では、関数greeting()が高階関数であり、print_word()がコールバック関数です。

コールバック関数の書き方には何種類かありますが、やっていることは同じです。関数を値として高階関数に渡して、後で実行しているということに過ぎません。

ダテネコ
ダテネコ

コールバック関数の定義には、非同期処理は関係無いんだぜ。

{"code":"\/\/ \u66f8\u304d\u65b9\u2460\nconst circle = (r) => {\n    return(r*r*3.14)\n}\nfunction diff(S, a, b){\n    return S(a) - S(b);\n}\n\nconsole.log(diff(circle, 5, 3));\n\/\/ 50.239999999999995","language":"javascript","id":1,"filename":""}

コールバック関数は渡しただけでは実行されず、高階関数中で()をつけて呼び出された時、はじめて関数として認識されて実行されます。

上記コード、diff()関数定義の中のreturn文のところですね。

つまり、関数を値として渡しただけで実行はしない、という宙ぶらりんな状態があるわけです。この概念、重要なので覚えておきましょう。

JavaScriptの非同期処理

さて、Javascriptの非同期処理とは何か、復習しておきましょう。非同期処理が何か分かっている方は、3章まで飛ばしてください。

記述の順序 ≠ 実行順序

以下のコードを実行すると、3秒後に”Hello, ” → “World!”の順に出力されそうですが、setTimeout()は非同期処理であるため、実際にはそうなりません。

{"code":"setTimeout(() => {\n    console.log('Hello,');\n}, 3000);\nconsole.log('World!');","language":"javascript","id":1,"filename":""}

実行直後に”World!”が出力され、約3秒後に遅れて”Hello”が出力されてしまいます。

// World!
// (3秒後)
// Hello,

setTimeout()が「3秒後に”Hello,”を出力する」というタスクを登録したら、”Hello,”の出力を待たずに次のタスクに進んでしまうからですね。

コマリブル
コマリブル

記述した順序で実行されないなんて、非同期処理は厄介だワン

しかし、JavaScriptにはデフォルトで非同期処理となるsetTimeout()のような関数が多くあります。

次節ではその理由を説明します。

JavaScriptはなぜ非同期処理を使うのか

JavaScriptが積極的に非同期処理を採用している理由は、言語の使用用途として非同期処理に明確な利点があるからです。

JavaScriptは原則的にシングルスレッドで動作し、かつUIの描画を行うメインスレッドで実行されます(※)。

ファイルの読み込みや、HTTPリクエストなど、時間がかかる(かも知れない)処理で他の処理をブロックすると、その間UIは描画されず、ページがフリーズしてしまうのです。

{"code":"\/\/\u30d5\u30a1\u30a4\u30eb\u8aad\u307f\u8fbc\u307f\u306e\u4f8b\nconst fs = require('fs');\nfs.readFile(\"foo.txt\", (error, data) => {\n    console.log(data);\n});\n\/\/\u4ed6\u306e\u30bf\u30b9\u30af\nconsole.log(\"Hello, World\");","language":"javascript","id":1,"filename":""}

WebサイトやWebアプリケーションでUIのフリーズが頻繁に起こると、ユーザー体験を著しく損ないますから、非同期処理で良いものは非同期処理で書く」がJSの基本的な考え方になったわけですね。

非同期処理の実行順序を制御する

JSでは非同期処理に利点があることが分かっても、実行順序の制御が出来なければ困ってしまいますね。 

非同期処理の入れ子で解決

実行から3秒後に”Hello, World!”を出力し、さらに3秒後に遅れて”Hello, JavaScript!”と出力するつもりで以下のように書くと、どうなるでしょうか。

{"code":"setTimeout(() => {\n     console.log('Hello, World!');\n }, 3000);\n \nsetTimeout(() => {\n    console.log('Hello, JavaScript!');\n}, 3000);\n\/\/ Hello, World!\n\/\/ Hello, JavaScript!\n\/\/ (\u307b\u307c\u540c\u6642)","language":"javascript","id":1,"filename":""}

ここまで読み進めた読者は、ほぼ同時に”Hello, World!”と、”Hello, JavaScript!”が表示されてしまうと分かりますね。

結論を言うと、正しい書き方は、setTimeout()の中で2つめのsetTimeout()を実行して後処理する書き方です。

{"code":"setTimeout(() => {\n    console.log('Hello, World!');\n    \/\/\u3053\u3053\u306b\u66f8\u304f\n    setTimeout(() => {\n        console.log('Hello, JavaScript!');\n    }, 3000);\n }, 3000);\n\n\/\/ \u30b3\u30f3\u30bd\u30fc\u30eb\u51fa\u529b\n\/\/ (3\u79d2)\n\/\/ Hello, World!\n\/\/ (3\u79d2)\n\/\/ Hello, JavaScript!","language":"javascript","id":1,"filename":""}
プログラムの構造
  • ① “Hello, World!”を出力する
  • ② 3秒後に、”Hello, JavaScript!”を出力する
  • タスク①②を、今から3秒後に行う

非同期処理の中で、非同期処理を行う入れ子にするわけですね。

次の節で、コールバック関数の観点から、考え方を整理していきましょう。

コールバック関数と高階関数の組み合わせ

前節のコードを、コールバック関数を明示した形で書いてみます。

{"code":"function delay_greeting(callback){\n    setTimeout(() => {\n        console.log('Hello, World!');\n        callback(); \/\/ \u3082\u3089\u3063\u305f\u95a2\u6570\u3092\u5b9f\u884c\n    }, 3000);\n}\nconst func = () => {\n    setTimeout(() => { console.log('Hello, JavaScript!'); }, 3000);\n}\ndelay_greeting(func);\n\n\/\/ \u30b3\u30f3\u30bd\u30fc\u30eb\u51fa\u529b\n\/\/ (3\u79d2)\n\/\/ Hello, World!\n\/\/ (3\u79d2)\n\/\/ Hello, JavaScript!","language":"javascript","id":1,"filename":""}

1つめのsetTimeout()が高階関数、後で実行したい2つめのsetTimeout()がコールバック関数になっています。

非同期処理を高階関数として書き、コールバック関数として別の処理を渡して、高階関数の中で実行すればいいことがわかりましたね。

まとめ

JavaScriptにおいて、コールバック関数を使用して非同期処理の実行順序制御ができる仕組みをまとめます。

ポイント
  • Javascriptでは、関数も「値」として扱うことができ、関数に渡すこともできる
  • 関数呼び出しを表す()を付けなければ、ただ渡しただけでは実行されない
  • コールバック関数の形で処理を渡しておいて、高階関数化した非同期関数の中で()をつけて実行すれば、好きなタイミングで実行できる

コールバック型非同期関数たち

コールバック関数と高階関数の組み合わせで非同期処理の実行順を制御できることが分かりました。

そのためJSには、コールバック関数を引数に取り、後処理を行う仕様の非同期関数があります。

requestモジュール | HTTPリクエスト

HTTPリクエストを送るrequestモジュール(※)は非同期関数で、レスポンスが返ってくることを待たずに次のタスクに移ります。

{"code":"const request = require('request');\nconst res = request('https:\/\/www.google.com');\nconsole.log(res.statusCode);\n\/\/ undefind","language":"javascript","id":1,"filename":""}

コールバック関数を使わずに書くと、レスポンスの完了前にconsole.log()が走ってしまうため、結果を出力できません。

正しい書き方は、コールバック関数をrequest()に渡して、その中に後処理を記述する書き方です。

{"code":"const request = require('request');\nrequest('https:\/\/www.google.com',function(error, response, body) {\n    console.log(response.statusCode);\n});\n\/\/ 200\n\/\/ \uff08\u6b63\u5e38\u7d42\u4e86\uff09","language":"javascript","id":1,"filename":""}

fs.readFile | ファイルの読み込み

2章で例示したnode.jsのjs.readFileは、ファイル読み込み中に他の処理をロックしない非同期関数です。

{"code":"const fs = require('fs');\n\nfs.readFile(\"foo.txt\", (error, data) => {\n    \/\/ \u30a8\u30e9\u30fc\u304c\u8d77\u304d\u305f\u3089\u51fa\u529b\n    If (error){\n        throw error;\n    }\n    \/\/ \u8aad\u307f\u8fbc\u3093\u3060\u30d5\u30a1\u30a4\u30eb\u5185\u5bb9\u306e\u8868\u793a\n    console.log(data);\n});","language":"javascript","id":1,"filename":""}

コールバック関数を引数に取る高階関数 という形は他と同じですね。

コールバック地獄

コールバック関数+高階関数で非同期処理を自由自在に扱えるかに見えますが、3つ以上の非同期処理や、複雑な制御になってくると行き詰まります。

fs.readFile()を例に、3つのファイルを逐次的に読み込まなければいけない例を考えます。

{"code":"const fs = require('fs');\n\nfs.readFile('foo.json', (error1, data1) => {\n    console.log(data1);\n    fs.readFile('bar.json', (error2, data2) => {\n        console.log(data2);\n        fs.readFile('baz.json', (error3, data3) => {\n           console.log(data3);\n           console.log(\"3 json files loaded.\");\n        })\n    })\n})","language":"javascript","id":1,"filename":""}
ツカレパンダ
ツカレパンダ

コードが読みにくくて疲れる。寝たい

コールバック型だと処理の数だけネストが必要になります。この状況は俗にコールバック地獄と呼ばれます。

実行順序を制御するためだけにどんどんネストを深くするのは、コードスタイルとしても良くないですよね。

Promiseの登場

ES2015から、コールバック地獄を防ぐことができるPromiseオブジェクトが導入されました。

実は、上記のコードはPromiseを使用すると以下のように書き直す事ができます。

{"code":"const fs = require('fs');\n\nfunction cat_file(filepath){\n    return new Promise((resolve, reject) => {\n        fs.readFile(filepath, (error, data) => {\n            console.log(data);\n            resolve();\n        })\n    });\n}\ncat_file('foo.json') \/\/ Promise\u3092\u8fd4\u3059\u95a2\u6570\n    .then(() => cat_file('bar.json'))\n    .then(() => cat_file('baz.json'))\n    .then(() => { console.log('3 json files loaded.') });","language":"javascript","id":1,"filename":""}

11行目から14行目まで、逐次処理している部分がコード上で明確ですね。

Promiseの基本文法について、以下の記事で詳細に解説しているので参考にしてください。

JavaScriptのPromiseをわかりやすく丁寧に解説する
シバセンセー
シバセンセー

ES2015以降のモダンJSでは、非同期処理はコールバック型ではなくPromiseを使用した書き方が基本になるんだよ。

ただし、Promise型非同期処理を理解する上でも、コールバック型非同期処理を理解している必要がありますので、レガシーと思わず一度理解しておきましょう。

以上で本記事は終了です。お疲れ様でした。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


error: Content is protected !!